Compare commits

...
1000 Commits
Author SHA1 Message Date
Tim Jaeryang Baek 171021cfa4 Merge pull request #17327 from open-webui/dev
0.6.28
2025-09-10 14:53:30 +04:00
Timothy Jaeryang Baek d3d7b209b2 refac: changelog styling 2025-09-10 14:50:10 +04:00
Tim Jaeryang Baek 6ddbb41aac Merge pull request #17333 from Classic298/patch-2
Chore: Update CHANGELOG
2025-09-10 14:38:45 +04:00
Classic298 2a9925fe18 Update CHANGELOG.md 2025-09-10 12:36:46 +02:00
Timothy Jaeryang Baek 81e401a520 doc: changelog 2025-09-10 14:34:32 +04:00
Tim Jaeryang Baek ba432090c5 Merge pull request #17320 from ShirasawaSama/patch-16
i18n: Update translations in zh-CN locale
2025-09-10 14:14:35 +04:00
Timothy Jaeryang Baek de634a7599 chore: dep bump 2025-09-10 14:08:16 +04:00
Timothy Jaeryang Baek 803daebfe4 chore: format 2025-09-10 14:06:47 +04:00
Timothy Jaeryang Baek 958a76d448 chore: bump 2025-09-10 14:06:12 +04:00
Tim Jaeryang Baek 2dd0eaad73 Merge pull request #17328 from open-webui/main
dev
2025-09-10 14:04:03 +04:00
Timothy Jaeryang Baek 8339f59cdf fix: openai audio revert 2025-09-10 14:02:19 +04:00
Timothy Jaeryang Baek c51a65173e enh: folder modal focus title 2025-09-10 13:55:29 +04:00
Timothy Jaeryang Baek d5cb65527e fix: prompt template 2025-09-10 13:52:34 +04:00
Timothy Jaeryang Baek 956cb7beaa fix: openai connections key 2025-09-10 13:49:19 +04:00
Tim Jaeryang Baek 4eb928693b Merge pull request #17317 from joaoback/patch-7
Update translation.json (pt-BR)
2025-09-10 13:00:43 +04:00
Timothy Jaeryang BaekandClassic298 d5b903234d chore: PR template
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-09-10 12:57:13 +04:00
Shirasawa 86f191d015 i18n: Update translations in zh-CN locale 2025-09-10 07:39:15 +00:00
joaoback b35b56394a Update translation.json (pt-BR)
I added some translations of new items
2025-09-09 15:25:07 -03:00
Timothy Jaeryang Baek 23990276cb chore: bump chroma 2025-09-09 19:13:41 +04:00
Timothy Jaeryang Baek f2a09c7149 fix: model command filter 2025-09-09 19:01:39 +04:00
Timothy Jaeryang Baek 774c0056bd refac: tool server data handling 2025-09-09 19:00:01 +04:00
Tim Jaeryang Baek c299d3fd54 Merge pull request #17299 from open-webui/dependabot/pip/backend/dev/psycopg2-binary-2.9.10
build(deps): bump psycopg2-binary from 2.9.9 to 2.9.10 in /backend
2025-09-09 18:48:00 +04:00
Tim Jaeryang Baek 7d62ce5b19 Merge pull request #17301 from open-webui/dependabot/pip/backend/dev/elasticsearch-9.1.0
build(deps): bump elasticsearch from 9.0.1 to 9.1.0 in /backend
2025-09-09 18:47:54 +04:00
dependabot[bot] 5ad93b6fc9 build(deps): bump psycopg2-binary from 2.9.9 to 2.9.10 in /backend
Bumps [psycopg2-binary](https://github.com/psycopg/psycopg2) from 2.9.9 to 2.9.10.
- [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS)
- [Commits](https://github.com/psycopg/psycopg2/compare/2.9.9...2.9.10)

---
updated-dependencies:
- dependency-name: psycopg2-binary
  dependency-version: 2.9.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:47:37 +00:00
Tim Jaeryang Baek e6a5618d13 Merge pull request #17302 from open-webui/dependabot/pip/backend/dev/googleapis-common-protos-1.70.0
build(deps): bump googleapis-common-protos from 1.63.2 to 1.70.0 in /backend
2025-09-09 18:46:52 +04:00
Tim Jaeryang Baek d335851a1c Merge pull request #17303 from open-webui/dependabot/pip/backend/dev/pgvector-0.4.1
build(deps): bump pgvector from 0.4.0 to 0.4.1 in /backend
2025-09-09 18:46:26 +04:00
Tim Jaeryang Baek 8bc6a30c7b Merge pull request #17297 from open-webui/dependabot/github_actions/dev/actions/setup-node-5
build(deps): bump actions/setup-node from 4 to 5
2025-09-09 18:43:58 +04:00
Tim Jaeryang Baek b7243e6efc Merge pull request #17298 from open-webui/dependabot/github_actions/dev/actions/setup-python-6
build(deps): bump actions/setup-python from 5 to 6
2025-09-09 18:43:51 +04:00
Tim Jaeryang Baek 40fab879ff Merge pull request #17300 from open-webui/dependabot/github_actions/dev/actions/github-script-8
build(deps): bump actions/github-script from 7 to 8
2025-09-09 18:43:44 +04:00
dependabot[bot] 5af4714bf0 build(deps): bump pgvector from 0.4.0 to 0.4.1 in /backend
Bumps [pgvector](https://github.com/pgvector/pgvector-python) from 0.4.0 to 0.4.1.
- [Changelog](https://github.com/pgvector/pgvector-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pgvector/pgvector-python/compare/v0.4.0...v0.4.1)

---
updated-dependencies:
- dependency-name: pgvector
  dependency-version: 0.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:35 +00:00
dependabot[bot] edd65feff5 build(deps): bump googleapis-common-protos in /backend
Bumps [googleapis-common-protos](https://github.com/googleapis/google-cloud-python) from 1.63.2 to 1.70.0.
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/packages/google-cloud-documentai/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/commits/googleapis-common-protos-v1.70.0)

---
updated-dependencies:
- dependency-name: googleapis-common-protos
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:32 +00:00
dependabot[bot] 202eada74f build(deps): bump elasticsearch from 9.0.1 to 9.1.0 in /backend
Bumps [elasticsearch](https://github.com/elastic/elasticsearch-py) from 9.0.1 to 9.1.0.
- [Release notes](https://github.com/elastic/elasticsearch-py/releases)
- [Commits](https://github.com/elastic/elasticsearch-py/compare/v9.0.1...v9.1.0)

---
updated-dependencies:
- dependency-name: elasticsearch
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:17 +00:00
dependabot[bot] d863fd3626 build(deps): bump actions/github-script from 7 to 8
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:13 +00:00
dependabot[bot] 3ff8deeea8 build(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:10 +00:00
dependabot[bot] 041a89ef5c build(deps): bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 14:35:06 +00:00
Tim Jaeryang Baek 918f507d8c Merge pull request #17070 from open-webui/dev
0.6.27
2025-09-09 18:34:15 +04:00
Timothy Jaeryang Baek f7e85cd0bf refac 2025-09-09 18:31:06 +04:00
Timothy Jaeryang Baek 4b4583df62 refac: styling 2025-09-09 18:28:22 +04:00
Timothy Jaeryang Baek bcf76dfa74 refac: changelog 2025-09-09 18:24:16 +04:00
Timothy Jaeryang Baek 485392fe63 chore: format 2025-09-09 18:19:31 +04:00
Timothy Jaeryang Baek cbb4684b16 chore 2025-09-09 18:16:24 +04:00
Timothy Jaeryang Baek 0531ca6530 refac/fix 2025-09-09 18:10:48 +04:00
Timothy Jaeryang Baek 32cb9df3c4 refac/enh: knowledge ac backend validation 2025-09-09 18:08:31 +04:00
Tim Jaeryang Baek 71fd483fba Merge pull request #17276 from Elettrotecnica/extend-docling-configuration
feat: Extend docling configuration options
2025-09-09 18:04:30 +04:00
Timothy Jaeryang Baek 63f38c584f refac 2025-09-09 18:02:51 +04:00
Timothy Jaeryang Baek 77779b30d4 refac 2025-09-09 18:01:59 +04:00
Timothy Jaeryang Baek d0f338bb99 refac/enh: ability to export/sync function valves 2025-09-09 17:48:41 +04:00
Timothy Jaeryang Baek 63ca0b8cba refac 2025-09-09 17:36:18 +04:00
Timothy Jaeryang Baek d0b20df46c refac: citations display 2025-09-09 17:27:38 +04:00
Timothy Jaeryang Baek 0214c1e66c refac 2025-09-09 16:48:59 +04:00
Tim Jaeryang Baek 015342e669 Merge pull request #17281 from Classic298/changelog-final
chore: Changelog 0.6.27 - New Changelog Style
2025-09-09 16:43:22 +04:00
Classic298 0cd32b015b Update CHANGELOG.md 2025-09-09 14:06:21 +02:00
Timothy Jaeryang Baek 5f0d262c59 fix: yt embed 2025-09-09 16:00:42 +04:00
Timothy Jaeryang Baek 7fc3ac38ac chore 2025-09-09 15:58:58 +04:00
Tim Jaeryang Baek a04e8ee84e Merge pull request #17295 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve Chinese translation
2025-09-09 15:46:57 +04:00
Shirasawa 3cce416cab feat: improve Chinese translation 2025-09-09 18:56:45 +08:00
Timothy Jaeryang Baek f17d8b5d19 refac: forward cred to static paths 2025-09-09 14:11:17 +04:00
Tim Jaeryang Baek c881e033b2 Merge pull request #17285 from ShirasawaSama/patch-15
feat: change default permission check for regenerate and delete actions
2025-09-09 14:06:28 +04:00
Timothy Jaeryang Baek 3ae547f796 refac 2025-09-09 14:03:47 +04:00
Timothy Jaeryang Baek 79e92378de refac 2025-09-09 14:02:08 +04:00
Timothy Jaeryang Baek 53ecfc2b5d chore: bump authlib 2025-09-09 13:46:21 +04:00
Shirasawa d783708745 feat: change default permission check for regenerate and delete actions 2025-09-09 15:42:58 +08:00
Classic298 218701e617 Update CHANGELOG.md 2025-09-08 23:42:58 +02:00
Classic298 c6a46195c0 Changelog dev (#18)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-09-08 23:40:39 +02:00
Timothy Jaeryang Baek 4a76bea80c refac 2025-09-08 21:03:29 +04:00
Timothy Jaeryang Baek fcebc82ec2 refac: chat controls panel resize logic 2025-09-08 20:54:18 +04:00
Antonio Pisano daa2a036f8 Extend docling configuration options to include:
* do_ocr
* force_ocr
* pdf_backend
* table_mode
* pipeline

as per https://github.com/docling-project/docling-serve/blob/main/docs/usage.md

See https://github.com/open-webui/open-webui/issues/17148
2025-09-08 18:51:33 +02:00
Timothy Jaeryang Baek 41f9a8caff refac 2025-09-08 20:25:19 +04:00
Timothy Jaeryang Baek 6f6412dd16 refac 2025-09-08 19:53:44 +04:00
Timothy Jaeryang Baek 2b2d123531 refac: oauth auth type in openai connection 2025-09-08 19:42:50 +04:00
Timothy Jaeryang Baek 474df5e534 refac 2025-09-08 19:18:55 +04:00
Timothy Jaeryang Baek 30d1dc2c60 refac 2025-09-08 19:12:20 +04:00
Timothy Jaeryang Baek 4cea3a57be refac 2025-09-08 19:09:26 +04:00
Timothy Jaeryang Baek 8a9f862701 refac 2025-09-08 19:07:00 +04:00
Timothy Jaeryang Baek 001dab0439 refac: wording 2025-09-08 18:55:57 +04:00
Timothy Jaeryang Baek b786d1e3f3 refac 2025-09-08 18:52:59 +04:00
Timothy Jaeryang Baek b5bb6ae177 refac 2025-09-08 18:50:23 +04:00
Timothy Jaeryang Baek f71834720e refac 2025-09-08 18:35:09 +04:00
Timothy Jaeryang Baek f11bdc6ab5 refac 2025-09-08 18:23:44 +04:00
Timothy Jaeryang Baek 35c1c48fd2 refac 2025-09-08 18:18:04 +04:00
Timothy Jaeryang Baek fc11e4384f refac 2025-09-08 18:17:11 +04:00
Timothy Jaeryang Baek 7693d0e2b0 refac 2025-09-08 18:09:01 +04:00
Timothy Jaeryang BaekandClassic298 217f4daef0 feat: server-side OAuth token management system
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-09-08 18:05:43 +04:00
Timothy Jaeryang Baek 6d38ac41b6 refac 2025-09-08 14:36:00 +04:00
Timothy Jaeryang Baek 91755309ce refac 2025-09-08 14:18:25 +04:00
Timothy Jaeryang Baek a28ca30519 refac/fix: source citation 2025-09-08 02:35:51 +04:00
Timothy Jaeryang Baek e023a98f11 refac: submit suggestion prompt by default 2025-09-07 19:24:32 +04:00
Timothy Jaeryang Baek 6dc0df2473 refac 2025-09-07 05:17:38 +04:00
Timothy Jaeryang Baek cd5e2be27b refac 2025-09-07 05:09:14 +04:00
Timothy Jaeryang Baek 3d37e4a42d refac 2025-09-07 05:06:03 +04:00
Timothy Jaeryang Baek 7f523de408 refac 2025-09-07 04:27:42 +04:00
Timothy Jaeryang Baek f2525ebc44 refac 2025-09-07 04:25:52 +04:00
Timothy Jaeryang Baek 1cdb7aed1e refac/enh: status history 2025-09-07 04:21:46 +04:00
Timothy Jaeryang Baek 16090bc272 refac 2025-09-07 03:01:25 +04:00
Timothy Jaeryang Baek 0a85dd4bca refac: web search link display 2025-09-07 03:00:28 +04:00
Timothy Jaeryang Baek 33f04a7714 refac: styling 2025-09-07 02:36:32 +04:00
Timothy Jaeryang Baek c9282135c4 refac 2025-09-07 02:02:21 +04:00
Timothy Jaeryang Baek 3d6d050ad8 refac/enh: display oauth error as toast 2025-09-07 01:48:52 +04:00
Timothy Jaeryang Baek 9368d0ac75 refac/fix: web search always on setting 2025-09-07 01:15:10 +04:00
Timothy Jaeryang Baek 40e40d1ddd enh/sec: verify folder data integrity
#17182
2025-09-07 01:04:56 +04:00
Timothy Jaeryang Baek d9f97c832c refac 2025-09-07 00:28:52 +04:00
Timothy Jaeryang Baek 4f2b821088 refac/enh: oauth redirect support 2025-09-07 00:22:52 +04:00
Timothy Jaeryang Baek eb10ff2ae6 refac: rm substandard code 2025-09-07 00:16:07 +04:00
Tim Jaeryang Baek b70e910e4b Merge pull request #17232 from open-webui/dependabot/pip/pip-83da9e8e4c
build(deps): bump the pip group across 2 directories with 2 updates
2025-09-07 00:00:14 +04:00
Timothy Jaeryang Baek 955fc5c736 refac: styling 2025-09-06 23:58:49 +04:00
Timothy Jaeryang Baek 9aac148908 fix: knowledge update backend issue 2025-09-06 23:44:29 +04:00
Tim Jaeryang Baek 183ba83920 Merge pull request #17245 from Classic298/patch-1
chore: changes to bug report issue template
2025-09-06 23:31:51 +04:00
Classic298 85cc067599 chore: changes to bug report issue template 2025-09-06 21:01:28 +02:00
Tim Jaeryang Baek fe0665605a Merge pull request #17229 from ShirasawaSama/feat/dynamic-load-heic2any
feat: dynamically load heic2any in channel MessageInput
2025-09-06 18:26:57 +04:00
Tim Jaeryang Baek e593ea8935 Merge pull request #17228 from ShirasawaSama/patch-13
feat: improve Chinese translation
2025-09-06 18:26:46 +04:00
dependabot[bot] e114a40bb8 build(deps): bump the pip group across 2 directories with 2 updates
Bumps the pip group with 2 updates in the / directory: [langchain-community](https://github.com/langchain-ai/langchain) and [pypdf](https://github.com/py-pdf/pypdf).
Bumps the pip group with 2 updates in the /backend directory: [langchain-community](https://github.com/langchain-ai/langchain) and [pypdf](https://github.com/py-pdf/pypdf).


Updates `langchain-community` from 0.3.26 to 0.3.27
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==0.3.26...langchain==0.3.27)

Updates `pypdf` from 4.3.1 to 6.0.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/4.3.1...6.0.0)

Updates `langchain-community` from 0.3.26 to 0.3.27
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==0.3.26...langchain==0.3.27)

Updates `pypdf` from 4.3.1 to 6.0.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/4.3.1...6.0.0)

---
updated-dependencies:
- dependency-name: langchain-community
  dependency-version: 0.3.27
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: pypdf
  dependency-version: 6.0.0
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: langchain-community
  dependency-version: 0.3.27
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: pypdf
  dependency-version: 6.0.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-05 15:50:11 +00:00
Shirasawa eb64f7bdb3 feat: dynamically load heic2any in channel MessageInput 2025-09-05 21:34:17 +08:00
Timothy Jaeryang Baek 1bc9711afd refac 2025-09-05 17:28:05 +04:00
Shirasawa de48a32ea2 feat: improve Chinese translation 2025-09-05 21:26:01 +08:00
Tim Jaeryang Baek 5b3eae3855 Merge pull request #17225 from ShirasawaSama/feat/dynamic-load-heic2any
feat: dynamically load heic2any to accelerate initial page loading speed and fix heic convert bug
2025-09-05 16:42:45 +04:00
Tim Jaeryang Baek 9764bf3b78 Merge pull request #17222 from ShirasawaSama/patch-14
feat: dynamically load PDFjs to accelerate initial page loading speed
2025-09-05 16:42:09 +04:00
Shirasawa a74ec200b3 feat: dynamically load heic2any to accelerate initial page loading speed 2025-09-05 20:07:12 +08:00
Shirasawa 49e045ea3d feat: dynamically load pdfjs 2025-09-05 17:55:04 +08:00
Timothy Jaeryang Baek c0a47169fa refac: emoji picker case sensitivity 2025-09-04 22:10:46 +04:00
Tim Jaeryang Baek a140cfc521 Merge pull request #17191 from Ithanil/fix_chat_button_text
fix: don't clip descending characters on chat buttons + don't capitalize
2025-09-04 20:23:40 +04:00
Tim Jaeryang Baek ea2f97004b Merge pull request #17186 from Viruzaum/tooltip-fix
fix: remove duplicated tooltip from follow-up
2025-09-04 20:22:59 +04:00
Tim Jaeryang Baek 93132abb40 Merge pull request #17195 from ShirasawaSama/patch-14
fix: fix error when stopping non-existent task
2025-09-04 20:21:09 +04:00
Tim Jaeryang Baek df0b1e5552 Merge pull request #17190 from aREversez/patch-17185-clean
i18n: improve Chinese localization
2025-09-04 20:13:46 +04:00
Shirasawa e5ea595425 fix: fix error when stopping non-existent task 2025-09-04 16:52:19 +08:00
Jan Kessler a5b781f3d8 prevent clipping of descending glyphs on chat button + don't capitalize 2025-09-04 09:28:16 +02:00
aREversez 5128402282 Update translation.json 2025-09-04 12:32:17 +08:00
Timothy Jaeryang Baek 11dea8795b refac 2025-09-04 02:56:21 +04:00
Timothy Jaeryang Baek b70c0f36c0 enh: emoji folder icon 2025-09-04 02:50:50 +04:00
Timothy Jaeryang Baek e42ee34672 refac 2025-09-03 20:56:25 +04:00
Timothy Jaeryang Baek 71b6a942fe refac: styling 2025-09-03 20:53:50 +04:00
Timothy Jaeryang Baek 5bdd334b74 enh: azure openai toggle 2025-09-03 20:28:29 +04:00
viruz 6d6794aa85 fix: remove duplicated tooltip from follow-up button in ResponseMessage component 2025-09-03 13:18:45 -03:00
Timothy Jaeryang Baek 1588f42fe7 refac 2025-09-03 20:17:43 +04:00
Timothy Jaeryang Baek 029db5c635 refac: styling 2025-09-03 19:59:27 +04:00
Timothy Jaeryang Baek b85700f6ca refac: styling 2025-09-03 19:13:17 +04:00
Timothy Jaeryang Baek fbbe1117ae refac/enh: file count in knowledge 2025-09-03 19:00:35 +04:00
Timothy Jaeryang Baek df66e21472 enh: regex pattern support for groups 2025-09-03 18:50:02 +04:00
Timothy Jaeryang Baek 51fc792501 refac: styling 2025-09-03 18:47:50 +04:00
Timothy Jaeryang Baek 7a166152d9 refac: styling 2025-09-03 18:40:25 +04:00
Timothy Jaeryang Baek 926954f93b refac: styling 2025-09-03 18:37:25 +04:00
Tim Jaeryang Baek 5a596ae40c Merge pull request #17180 from Viruzaum/untitled-translation-fix
fix/i18n: Fix missing translation of default title of AddTextContentModel
2025-09-03 18:05:56 +04:00
Tim Jaeryang Baek 048f30aa97 Merge pull request #17158 from sihyeonn/fix/sh-cache
perf: fix cache key generation for model list caching
2025-09-03 16:18:33 +04:00
Timothy Jaeryang Baek 66bf28cd85 refac 2025-09-03 15:48:07 +04:00
Timothy Jaeryang Baek bbe1167958 refac/fix: pyodide import issue 2025-09-03 15:46:42 +04:00
Tim Jaeryang Baek 357a53eb6f Merge pull request #17147 from anfuerer/dev
feat: Azure OpenAI image generation support
2025-09-03 14:12:48 +04:00
Timothy Jaeryang Baek 37bf0087e5 refac: tool message format 2025-09-03 13:57:14 +04:00
Tim Jaeryang Baek e5829572ff Merge pull request #17175 from garylab/bugfix/api-config
Bugfix: Add verify token from headers also for /api/config endpoint
2025-09-03 13:39:25 +04:00
Timothy Jaeryang Baek 4ca936f0bf refac 2025-09-03 13:38:07 +04:00
Tim Jaeryang Baek 0a351580f8 Merge pull request #17137 from acwoo97/feat/knowledge-update-race-condition
fix: prevent double-save race by awaiting API calls and adding isSaving guard
2025-09-03 13:36:51 +04:00
Tim Jaeryang Baek 472b71f331 Merge pull request #17166 from sihyeonn/perf/sh-model-layer
perf: fix N+1 query issues in user group access control validation
2025-09-03 13:36:01 +04:00
Tim Jaeryang Baek 308f4d6b26 Merge pull request #17159 from sihyeonn/perf/sh-prompts
perf: fix N+1 query issue in get_prompts method
2025-09-03 13:26:29 +04:00
Gary Meng 2d62796616 Allow user get /api/config with auth header 2025-09-03 13:25:09 +04:00
Tim Jaeryang Baek 330bec67b7 Merge pull request #17162 from sihyeonn/perf/sh-tools
perf: fix N+1 query issue in get_tools method
2025-09-03 13:22:37 +04:00
Tim Jaeryang Baek 2890c6d62d Merge pull request #17161 from sihyeonn/perf/sh-models
perf: fix N+1 query issue in get_models method
2025-09-03 13:22:27 +04:00
Tim Jaeryang Baek 9119db001d Merge pull request #17160 from sihyeonn/perf/sh-knowledge-base
perf: fix N+1 query issue in get_knowledge_bases method
2025-09-03 13:22:19 +04:00
Tim Jaeryang Baek c7faccd5fa Merge pull request #17165 from sihyeonn/perf/sh-tools-router
perf: fix N+1 query issue in tools access control checking
2025-09-03 13:13:49 +04:00
Sihyeon Jang eff06538a6 perf: fix N+1 query issues in user group access control validation
- Pre-fetch user group IDs in get_*_by_user_id methods across models layer
- Pass user_group_ids to has_access to avoid repeated group queries
- Reduce query count from 1+N to 1+1 pattern for access control validation
- Apply consistent optimization across knowledge, models, notes, prompts, and tools

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:56:48 +09:00
Sihyeon Jang 0503fbd2e3 perf: fix N+1 query issue in tools access control checking
- Pre-fetch user group IDs once per request in get_tools endpoint
- Pass user_group_ids to has_access to avoid repeated group queries
- Optimize access control validation from 1+N to 1+1 query pattern
- Reduce database load when checking multiple tools access permissions

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:49:53 +09:00
Sihyeon Jang 03d1d2a88b perf: fix N+1 query issue in get_tools method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern for tools listing

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:35:35 +09:00
Sihyeon Jang c0b3db38a5 perf: fix N+1 query issue in get_models method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern for models with base_model_id

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:33:41 +09:00
Sihyeon Jang f588655f7f perf: fix N+1 query issue in get_knowledge_bases method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:29:47 +09:00
Sihyeon Jang c45201a8a2 perf: fix N+1 query issue in get_prompts method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:25:26 +09:00
Sihyeon Jang 3ccbb46938 perf: fix cache key generation for model list caching
- Replace Request object with user.id in cache key for get_all_models
- Request objects are new instances per HTTP request, preventing cache hits
- Cache keys now use user.id ensuring proper cache functionality
- Affects both Ollama and OpenAI model list endpoints

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:17:41 +09:00
viruz a980af3ca4 fix: update default name to use i18n translation 2025-09-02 15:47:16 -03:00
Timothy Jaeryang Baek 22c4ef4fb0 enh: delete_file query param 2025-09-02 21:32:07 +04:00
Andreas Fuerer df0d29c81c style: fix formatting issues 2025-09-02 19:16:02 +02:00
Andreas Fuerer bc6afc9057 feature: Azure OpenAI image generation support
The image generation API used on Azure OpenAI requires to specify the API
version by appending an `api-version` query parameter to the endpoint URL.
Added the environment variable `IMAGES_OPENAI_API_VERSION` with
configuration functionality in the administration UI.
2025-09-02 15:51:45 +02:00
Timothy Jaeryang Baek 182408a52e refac 2025-09-02 17:03:59 +04:00
Chanwoo An 513cab94b1 fix: prevent double-save race by awaiting API calls and adding isSaving guard 2025-09-02 20:53:04 +09:00
Tim Jaeryang Baek d01439d144 Merge pull request #17093 from open-webui/dependabot/pip/backend/dev/argon2-cffi-25.1.0
build(deps): bump argon2-cffi from 23.1.0 to 25.1.0 in /backend
2025-09-02 02:41:49 +04:00
Tim Jaeryang Baek 0a535a6fed Merge pull request #17107 from rgaricano/dev-es_ES
UPD: i18n es-ES Translation v.0.6.26
2025-09-01 22:52:14 +04:00
Tim Jaeryang Baek 3a38173c66 Merge pull request #17098 from open-webui/dependabot/github_actions/dev/actions/download-artifact-5
build(deps): bump actions/download-artifact from 4 to 5
2025-09-01 22:51:34 +04:00
Tim Jaeryang Baek adbaaa65f9 Merge pull request #17097 from open-webui/dependabot/npm_and_yarn/dev/tiptap/extension-highlight-3.3.0
build(deps): bump @tiptap/extension-highlight from 3.0.7 to 3.3.0
2025-09-01 22:51:23 +04:00
Tim Jaeryang Baek 379592dae4 Merge pull request #17096 from open-webui/dependabot/pip/backend/dev/pytest-approx-eq-8.4.1
build(deps): update pytest requirement from ~=8.3.5 to ~=8.4.1 in /backend
2025-09-01 22:51:02 +04:00
Tim Jaeryang Baek 22ff239e3a Merge pull request #17094 from open-webui/dependabot/pip/backend/dev/youtube-transcript-api-1.2.2
build(deps): bump youtube-transcript-api from 1.1.0 to 1.2.2 in /backend
2025-09-01 22:50:57 +04:00
Timothy Jaeryang Baek e830b4959e enh: llama cpp timing stats 2025-09-01 22:49:49 +04:00
Tim Jaeryang Baek 1c1b32b390 Merge pull request #17099 from open-webui/dependabot/github_actions/dev/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-09-01 22:48:04 +04:00
Timothy Jaeryang Baek e6daad2ab9 chore: bump mermaid 2025-09-01 22:45:06 +04:00
Timothy Jaeryang Baek 4f2e426fc7 refac 2025-09-01 14:27:20 +04:00
Timothy Jaeryang Baek 609a6a3721 refac 2025-09-01 14:22:02 +04:00
Timothy Jaeryang Baek 85153afda8 refac 2025-09-01 14:21:17 +04:00
Timothy Jaeryang Baek f56889c5c7 fix: fillter exception handling 2025-09-01 14:14:20 +04:00
_00_ cef4028c1c UPD: i18n Translation es-ES v.0.6.27
### Update of i18n Translation es-ES v.0.6.27

Added new strings
2025-09-01 09:48:05 +02:00
Timothy Jaeryang Baek 4b97884fce refac 2025-09-01 11:46:52 +04:00
Tim Jaeryang Baek 9ac7f2635c Merge pull request #17101 from open-webui/dependabot/npm_and_yarn/dev/dompurify-3.2.6
build(deps): bump dompurify from 3.2.5 to 3.2.6
2025-09-01 10:37:39 +04:00
Tim Jaeryang Baek 2d6f68eb2d Merge pull request #17095 from open-webui/dependabot/npm_and_yarn/dev/pyodide-0.28.2
build(deps): bump pyodide from 0.27.7 to 0.28.2
2025-09-01 10:37:28 +04:00
Tim Jaeryang Baek 61e7aa2229 Merge pull request #17089 from open-webui/dependabot/npm_and_yarn/dev/pdfjs-dist-5.4.149
build(deps): bump pdfjs-dist from 5.3.93 to 5.4.149
2025-09-01 10:35:49 +04:00
dependabot[bot] 9f5df72d7c build(deps): bump dompurify from 3.2.5 to 3.2.6
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.5 to 3.2.6.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.2.5...3.2.6)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:16:43 +00:00
dependabot[bot] 4dd9484b48 build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:03:14 +00:00
dependabot[bot] dbe36e841a build(deps): bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:57 +00:00
dependabot[bot] 201eaa9655 build(deps): bump @tiptap/extension-highlight from 3.0.7 to 3.3.0
Bumps [@tiptap/extension-highlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-highlight) from 3.0.7 to 3.3.0.
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-highlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.3.0/packages/extension-highlight)

---
updated-dependencies:
- dependency-name: "@tiptap/extension-highlight"
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:48 +00:00
dependabot[bot] 4cd550d1d0 build(deps): update pytest requirement in /backend
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...8.4.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:31 +00:00
dependabot[bot] 94dae76d0f build(deps): bump pyodide from 0.27.7 to 0.28.2
Bumps [pyodide](https://github.com/pyodide/pyodide) from 0.27.7 to 0.28.2.
- [Release notes](https://github.com/pyodide/pyodide/releases)
- [Commits](https://github.com/pyodide/pyodide/compare/0.27.7...0.28.2)

---
updated-dependencies:
- dependency-name: pyodide
  dependency-version: 0.28.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:01:03 +00:00
dependabot[bot] 3e69f10af7 build(deps): bump youtube-transcript-api from 1.1.0 to 1.2.2 in /backend
Bumps [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) from 1.1.0 to 1.2.2.
- [Release notes](https://github.com/jdepoix/youtube-transcript-api/releases)
- [Commits](https://github.com/jdepoix/youtube-transcript-api/compare/v1.1.0...v1.2.2)

---
updated-dependencies:
- dependency-name: youtube-transcript-api
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:59:27 +00:00
dependabot[bot] bcc0cbf895 build(deps): bump argon2-cffi from 23.1.0 to 25.1.0 in /backend
Bumps [argon2-cffi](https://github.com/hynek/argon2-cffi) from 23.1.0 to 25.1.0.
- [Release notes](https://github.com/hynek/argon2-cffi/releases)
- [Changelog](https://github.com/hynek/argon2-cffi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/argon2-cffi/compare/23.1.0...25.1.0)

---
updated-dependencies:
- dependency-name: argon2-cffi
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:59:19 +00:00
dependabot[bot] 0e0c1c4ca4 build(deps): bump pdfjs-dist from 5.3.93 to 5.4.149
Bumps [pdfjs-dist](https://github.com/mozilla/pdf.js) from 5.3.93 to 5.4.149.
- [Release notes](https://github.com/mozilla/pdf.js/releases)
- [Commits](https://github.com/mozilla/pdf.js/compare/v5.3.93...v5.4.149)

---
updated-dependencies:
- dependency-name: pdfjs-dist
  dependency-version: 5.4.149
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:53:53 +00:00
Timothy Jaeryang Baek ed5d95f434 refac 2025-09-01 02:41:29 +04:00
Tim Jaeryang Baek 3d2fcef28f Merge pull request #17085 from ShirasawaSama/patch-10
fix: fix event binding for composition end in MessageInput
2025-09-01 02:10:04 +04:00
Timothy Jaeryang Baek 77b65ccbfb refac/enh: forward headers to tool server 2025-09-01 01:52:10 +04:00
Timothy Jaeryang Baek 487979859a fix: web/youtube attachements 2025-09-01 01:22:50 +04:00
Timothy Jaeryang Baek ac0243e8b7 refac 2025-09-01 00:57:13 +04:00
Shirasawa 5037197541 fix: fix event binding for composition end in MessageInput 2025-09-01 04:49:06 +08:00
Timothy Jaeryang Baek b45219c8b1 refac 2025-09-01 00:04:26 +04:00
Tim Jaeryang Baek ebfc887869 Merge pull request #17061 from AdamJohnSwan/dev
fix: for forming TTS request URL when there is a slash at the end of the base URL
2025-09-01 00:01:43 +04:00
Tim Jaeryang Baek 719d115d49 Merge pull request #17049 from rgaricano/dev-FIX_lex-sem
FIX: Hybrid Search
2025-09-01 00:00:25 +04:00
Tim Jaeryang Baek 4e7b0ea4b4 Merge pull request #17013 from athoik/fix-17000
fix: handle unicode filenames in external document loader
2025-08-31 23:58:52 +04:00
Timothy Jaeryang Baek c2b4976c82 enh: PGVECTOR_CREATE_EXTENSION env var 2025-08-31 23:58:18 +04:00
Timothy Jaeryang Baek e0ab5adb97 refac 2025-08-31 23:52:50 +04:00
Timothy Jaeryang Baek b0f6f24ca8 refac 2025-08-31 23:42:34 +04:00
Timothy Jaeryang Baek 61f530ff4b refac: styling 2025-08-31 23:06:58 +04:00
Tim Jaeryang Baek 7df1a059d6 Merge pull request #17012 from thomascooper/fork/feature/20250828-auth-oidc-support-pkce
fix: PKCE requires no secret, with no secret the login button does not eve…
2025-08-31 14:29:05 +04:00
Tim Jaeryang Baek 3e18187805 Merge pull request #17029 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-08-31 13:59:40 +04:00
Tim Jaeryang Baek aa13ed885d Merge pull request #17019 from ShirasawaSama/patch-8
i18n: improve Chinese translations
2025-08-31 13:59:29 +04:00
Tim Jaeryang Baek 2a27db2d3b Merge pull request #17064 from hadadarjt/dev
fix: Resolve admin account creation on Hugging Face Spaces.
2025-08-31 13:34:20 +04:00
Tim Jaeryang Baek 89f005ece6 Merge pull request #17017 from joaoback/patch-3
Update translation.json (pt-BR)
2025-08-31 13:33:28 +04:00
Hadad 48afd424a3 fix: Resolve admin account creation on Hugging Face Spaces.
Signed-off-by: Hadad <hadad@linuxmail.org>
2025-08-31 14:33:48 +07:00
Adam 39ae9167ee removed test code. 2025-08-30 20:31:13 -04:00
Adam 20b6902b9f whitespace 2025-08-30 20:30:34 -04:00
Adam 08b958cfc9 re-add used var 2025-08-30 20:29:16 -04:00
Adam c62f30e22c remove whitespace 2025-08-30 20:12:46 -04:00
Adam 562710fe33 join the url instead of concatenating a string in case the user adds a slash to the end of their configured url. 2025-08-30 20:05:53 -04:00
_00_ 647e38f701 Revert bypass hybrid search when BM25_weight=0
Revert PR https://github.com/open-webui/open-webui/commit/74b1c801
2025-08-30 10:45:35 +02:00
_00_ 292cb62d4a FIX: Hybrid Search lexical-semantic tags
FIX Error in Hybrid Search lexical-semantic terms places

I was reviewing and I noticed that the lexical-semantic terms are inverted.

BM25 weight=1 --> lexical
BM25 weight=0 --> semantic
2025-08-30 01:48:31 +02:00
Aleix Dorca 32a303f392 Update catalan translation.json 2025-08-29 11:59:09 +02:00
Shirasawa 1d1a83b754 i18n: improve zh-CN translation 2025-08-29 03:49:15 +00:00
joaoback 1ca5ad47b1 Update translation.json (pt-BR)
Some minor translations improvements
2025-08-28 19:49:23 -03:00
Timothy Jaeryang Baek 3111d1bf61 refac 2025-08-29 02:07:31 +04:00
Athanasios Oikonomou d735b036fe fix: handle unicode filenames in external document loader
Files with special characters in their names (e.g., ü.pdf) caused issues since HTTP headers only allow Latin-1 characters.
This change URL-encodes `X-Filename` before adding it to request headers, preventing failures when uploading or processing such files.

Fixes: #17000
2025-08-28 22:19:50 +03:00
Thomas Cooper 9d80cc3b2d PKCE requires no secret, with no secret the login button does not ever show 2025-08-28 14:47:13 -04:00
Timothy Jaeryang Baek 0ebe4f8f84 refac: conditional USE_PERMISSION_HARDENING 2025-08-28 20:19:47 +04:00
Timothy Jaeryang Baek be373e9fd4 refac: dockerfile 2025-08-28 19:42:28 +04:00
Timothy Jaeryang Baek 898826dc22 Update dependabot.yml 2025-08-28 19:16:50 +04:00
Timothy Jaeryang Baek 0bca4e230e refac: rename tools to external tools for clarity 2025-08-28 15:08:13 +04:00
Timothy Jaeryang Baek ef5374a34e typo 2025-08-28 14:46:47 +04:00
Tim Jaeryang Baek 2407d9b905 Merge pull request #16859 from open-webui/dev
0.6.26
2025-08-28 14:40:19 +04:00
Timothy Jaeryang Baek 25d9a728f4 refac 2025-08-28 14:24:49 +04:00
Timothy Jaeryang Baek 103688936e doc: changelog 2025-08-28 14:19:12 +04:00
Tim Jaeryang Baek ac73f97604 Merge pull request #16993 from Classic298/patch-1
Changelog for 0.6.26
2025-08-28 14:05:53 +04:00
Classic298 0d09571ca2 Changelog for 0.6.26 2025-08-28 12:00:40 +02:00
Tim Jaeryang Baek 5b4409814b Merge pull request #16982 from a1cd/main
ci: slim image
2025-08-28 13:29:15 +04:00
Timothy Jaeryang Baek 8cca648efa refac 2025-08-28 13:25:48 +04:00
Timothy Jaeryang Baek a8222e391b chore: dep bump 2025-08-28 13:05:06 +04:00
Timothy Jaeryang Baek dca7ce9420 refac 2025-08-28 03:41:25 +04:00
Timothy Jaeryang Baek bcd5783752 chore: format 2025-08-28 03:38:06 +04:00
Timothy Jaeryang Baek 12bd04d24d refac/enh 2025-08-28 03:24:26 +04:00
Timothy Jaeryang Baek 2bb6063dcb refac/fix: marker 2025-08-28 03:03:31 +04:00
Timothy Jaeryang Baek 4d7c8b4a6a refac 2025-08-28 03:01:53 +04:00
Timothy Jaeryang Baek 40617b9e0e refac: file item modal 2025-08-28 02:59:45 +04:00
Timothy Jaeryang Baek 52030a241c refac 2025-08-28 02:50:19 +04:00
Timothy Jaeryang Baek 3d6605bbfd refac: hide steps in images 2025-08-28 02:48:08 +04:00
Everett Wilber a60b0a108a Ensure data directory exists before chown 2025-08-27 18:46:31 -04:00
Timothy Jaeryang Baek 48635ced35 refac 2025-08-28 02:45:06 +04:00
Everett Wilber f4dde86b36 Fix syntax error in Dockerfile pip install command 2025-08-27 18:40:17 -04:00
Everett Wilber fcc1e2729c Fix Dockerfile syntax for conditional installation 2025-08-27 18:37:49 -04:00
Timothy Jaeryang Baek e7c7c65227 refac/fix: error message 2025-08-28 02:35:29 +04:00
Everett Wilber b2d1aa3c6e Fix syntax error in conditional for Ollama installation 2025-08-27 18:35:00 -04:00
Everett Wilber 24767bb0fa Merge pull request #2 from a1cd/slimify
Slimify
2025-08-27 18:31:39 -04:00
Everett Wilber c000188728 Merge pull request #1 from a1cd/slimify-patch-1
Slimify patch 1
2025-08-27 18:29:28 -04:00
Everett Wilber db4adc0e89 Add build-slim-image job to Docker workflow 2025-08-27 18:27:16 -04:00
Timothy Jaeryang Baek cf08d34879 refac 2025-08-28 02:24:21 +04:00
Everett Wilber d2fdf6999b Add USE_SLIM argument to Dockerfile 2025-08-27 18:20:23 -04:00
Timothy Jaeryang Baek d9c8a2508f chore: format 2025-08-28 01:42:45 +04:00
Tim Jaeryang Baek df2428b356 Merge pull request #16965 from joaoback/patch-2
Update translation.json (pt-BR)
2025-08-27 17:25:54 +04:00
Timothy Jaeryang Baek e39ce16a86 enh: custom reasoning tags 2025-08-27 17:24:16 +04:00
joaoback d67cf5d9f2 Update translation.json (pt-BR)
I made some adjustments to some translations and also translated new items added.
2025-08-27 09:18:50 -03:00
Tim Jaeryang Baek 688bfac4ba Merge pull request #16946 from ShirasawaSama/patch-7
i18n: improve Chinese translation
2025-08-27 15:46:05 +04:00
Timothy Jaeryang Baek 7c2cb5899c refac 2025-08-27 15:25:24 +04:00
Shirasawa b16ad094be i18n: improve zh-CN translation 2025-08-27 17:06:45 +08:00
Tim Jaeryang Baek d5715cec6f Merge pull request #16944 from rgaricano/dev-FIX_RTL_in_messages-LTR_codeblock
FIX: Auto RTL-LTR text orientation in Messages & Reversion previous PR
2025-08-27 12:55:18 +04:00
Shirasawa c7caa268ce i18n: improve zh-TW translation 2025-08-27 14:08:32 +08:00
Shirasawa de8e8af989 i18n: improve zh-CN translation 2025-08-27 12:04:20 +08:00
_00_ ddc88d615c FIX: Auto RTL-LTR text orientation
FIX: Auto RTL-LTR text orientation

 This tag was a `<div>` and changed by `<li>` in commit https://github.com/open-webui/open-webui/commit/bb6864dd127e8bc0b0467dfb9eb87f460acecfa8#r164667886
& this change broke the previous behavior of auto text orientation.
2025-08-27 03:58:18 +02:00
_00_ 7644d66902 Revert last change in ResponseMessage.svelte 2025-08-27 03:53:51 +02:00
_00_ 177788540b Revert last changes in UserMessage.svelte 2025-08-27 03:51:16 +02:00
_00_ 65a9a7eae0 Revert changes for fix orientation in CodeBlock.svelte
Revert changes for fix orientation in CodeBlock.svelte
2025-08-27 03:46:07 +02:00
_00_ 804340ee26 Merge branch 'open-webui:main' into dev-FIX_RTL_in_messages-LTR_codeblock 2025-08-27 03:40:01 +02:00
Timothy Jaeryang Baek 937f97d7d6 chore: format 2025-08-27 04:28:24 +04:00
Timothy Jaeryang Baek f6637cc78e refac 2025-08-27 04:18:18 +04:00
Timothy Jaeryang Baek 29cd1bc739 enh: pull to refresh 2025-08-27 04:16:02 +04:00
Timothy Jaeryang BaekandJacob Leksan 31485835a7 enh: query caching
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2025-08-27 03:07:21 +04:00
Timothy Jaeryang Baek e4b6855984 enh: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES 2025-08-27 02:58:25 +04:00
Timothy Jaeryang Baek bbd3e38e25 enh: a11y 2025-08-27 00:52:18 +04:00
Timothy Jaeryang Baek b29d5b80cd refac: pypi optional-dependencies 2025-08-26 23:05:44 +04:00
Timothy Jaeryang Baek edf7a3d142 refac 2025-08-26 22:05:27 +04:00
Timothy Jaeryang Baek 8b425a6295 refac 2025-08-26 21:56:52 +04:00
Timothy Jaeryang Baek d42e3fce4a refac 2025-08-26 21:26:18 +04:00
Timothy Jaeryang Baek 58cc57e8a4 refac 2025-08-26 17:39:52 +04:00
Timothy Jaeryang Baek d3a952877a refac: pdf export 2025-08-26 17:34:33 +04:00
Timothy Jaeryang Baekand_00_ 07357afcf6 refac
Co-Authored-By: _00_ <131402327+rgaricano@users.noreply.github.com>
2025-08-26 16:54:36 +04:00
Timothy Jaeryang Baek ceb4948a28 refac 2025-08-26 16:14:43 +04:00
Timothy Jaeryang Baek 86a8eb1023 refac/enh: pdf export 2025-08-26 16:05:02 +04:00
Timothy Jaeryang Baek dced9e4094 refac 2025-08-26 15:06:50 +04:00
Timothy Jaeryang BaekandG30 803b2e35be enh: delete_message, continue_response, regenerate_response, rate_response user permissions
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2025-08-26 15:05:35 +04:00
Timothy Jaeryang Baek 23a9731899 refac/fix: hybrid search 2025-08-26 15:04:46 +04:00
Tim Jaeryang Baek 4267e22d4a Merge pull request #16826 from selenecodes/feat/azure-document-intelligence-azure-entra-auth
feat: Authenticate Azure Document Intelligence using DefaultAzureCredential
2025-08-26 14:32:04 +04:00
Tim Jaeryang Baek e3207f35d7 Merge pull request #16885 from ShirasawaSama/patch-5
fix: fix Safari IME composition bug (#16615)
2025-08-26 14:26:12 +04:00
Tim Jaeryang Baek 85264e76de Merge pull request #16918 from ShirasawaSama/patch-6
fix: fix Windows sidebar button cursor icon
2025-08-26 13:52:29 +04:00
Tim Jaeryang Baek 1dc8056d84 Merge pull request #16853 from yuliang615/dev
Fix:Copy button in code blocks copies the original AI output instead of the edited content
2025-08-26 13:51:30 +04:00
Tim Jaeryang Baek 853c14b4c2 Merge pull request #16897 from ibuki2003/fix_translation_ja
i18n: improve ja-JP translation
2025-08-26 13:48:46 +04:00
Tim Jaeryang Baek 01dba25466 Merge pull request #16915 from SantiagoDePolonia/pl-translation-fix
i18n: updated Polish translation
2025-08-26 13:47:09 +04:00
Shirasawa 9c3f54cf1c fix: fix Windows sidebar button cursor style 2025-08-26 09:23:52 +00:00
Timothy Jaeryang Baek f4047eea77 fix: direct tool server 2025-08-26 13:15:47 +04:00
Jakub A. W 0583847943 fix: updated Polish translation 2025-08-26 06:45:01 +02:00
Tim Jaeryang Baek 4b22aa819c Merge pull request #16878 from rgaricano/dev-FIX_RTL_in_messages-LTR_codeblock
FIX- RTL text orientation in Messages, and LTR allways for CodeBlock
2025-08-26 02:25:01 +04:00
Timothy Jaeryang Baek 630cea105e fix: empty content in file modal 2025-08-25 18:22:28 +04:00
Timothy Jaeryang Baek c61698efcf enh: process_in_background query param for file upload endpoint 2025-08-25 18:18:52 +04:00
Timothy Jaeryang Baek a37d411dcf refac 2025-08-25 18:12:47 +04:00
ibuki2003 eacec793e9 i18n: improve ja-JP translation 2025-08-25 22:18:10 +09:00
Timothy Jaeryang Baek 0e46f8091f fix: __tools__ param issue 2025-08-25 14:29:05 +04:00
Shirasawa 7b9dc69362 fix: fix Safari IME composition bug 2025-08-25 14:37:51 +08:00
_00_ 0a16af4e96 rerun checks 2025-08-25 00:20:20 +02:00
_00_ 24e696e341 Format Update ResponseMessage.svelte 2025-08-25 00:01:01 +02:00
Timothy Jaeryang Baek 0ea421ea20 refac 2025-08-25 01:12:14 +04:00
Tim Jaeryang Baek 3da6049a4a Merge pull request #16862 from rgaricano/dev-es_ES
UPD: i18n es-ES Translation v.0.6.25
2025-08-25 01:11:16 +04:00
_00_ 3839f18bb1 FIX- LTR in Update CodeBlock.svelte
FIX- LTR in Update CodeBlock.svelte
For CodeBlock allways LTR
2025-08-24 23:07:32 +02:00
_00_ 2b0e90c032 FIX RTL in Update ResponseMessage.svelte
FIX RTL in Update ResponseMessage.svelte
2025-08-24 23:04:57 +02:00
_00_ 3a360e441e FIX-RTL in UserMessage.svelte
FIX-RTL in UserMessage.svelte
2025-08-24 23:03:24 +02:00
_00_ a463e58de6 Update translation.json
correctión lost "
2025-08-23 20:59:49 +02:00
_00_ 7a59cc9186 Update translation.json
Gramatical correction
2025-08-23 20:58:12 +02:00
_00_ ff6946c230 UPD: i18n es-ES Translation v.0.6.25
UPD: i18n es-ES Translation v.0.6.25

Added new strings in es-ES Translation
2025-08-23 20:53:32 +02:00
Tim Jaeryang Baek 3ff773c199 Merge pull request #16838 from silentoplayz/shift-to-delete-prompts
feat: Add Shift-to-delete functionality for prompts on Workspace `Prompts` page
2025-08-23 21:57:09 +04:00
Tim Jaeryang Baek 8048d4ac39 Merge pull request #16850 from rgaricano/dev-FIX_playwright_timeout
FIX: Playwright Timeout (ms) interpreted as seconds
2025-08-23 21:55:48 +04:00
Tim Jaeryang Baek 8d7319441e Merge pull request #16848 from Kylapaallikko/dev
i18n: Update fi-FI translation
2025-08-23 21:51:16 +04:00
洪宇亮 d98a60fbbb Add files via upload 2025-08-23 21:42:35 +08:00
_00_ 093af754e7 FIX: Playwright Timeout (ms) interpreted as seconds
Fix for Playwright Timeout (ms) interpreted as seconds.

To address https://github.com/open-webui/open-webui/issues/16801

In Frontend Playwright Timeout is setted as (ms), but in backend is interpreted as (s) doing a time conversion for playwright_timeout var (that have to be in ms).

& as  _Originally posted by @rawbby in [#16801](https://github.com/open-webui/open-webui/issues/16801#issuecomment-3216782565)_

> I personally think milliseconds are a reasonable choice for the timeout. Maybe the conversion should be fixed, not the label.
> This would further not break existing configurations from users that rely on their current config.
>
2025-08-23 14:15:00 +02:00
Kylapaallikko fa1590df57 Update fi-FI translation.json
Added missing translations and fixed typos.
2025-08-23 09:16:49 +03:00
silentoplayz 9e7ec0eb1e feat: add shift-to-delete to prompts workspace
This commit adds the Shift key shortcut to the Prompts workspace page to reveal a trash can icon for quick deletion of prompts. This feature is already present for the Models, Tools, and Functions pages.
2025-08-22 16:09:21 -04:00
Selene Blok 5051bfe7ab feat(document retrieval): Authenticate Azure Document Intelligence using AzureDefaultCredential if API key is not provided 2025-08-22 16:15:43 +02:00
Tim Jaeryang Baek 1db8dec4f5 Merge pull request #16821 from open-webui/dev
0.6.25
2025-08-22 17:22:31 +04:00
Timothy Jaeryang Baek dafedf5675 chore: bump 2025-08-22 17:20:05 +04:00
Timothy Jaeryang Baek 37a3de0703 fix 2025-08-22 17:19:57 +04:00
Timothy Jaeryang Baek 72b25ab78b fix: image generation 2025-08-22 16:58:25 +04:00
Timothy Jaeryang Baek fbff4e19de fix: reranking 2025-08-22 16:47:05 +04:00
Tim Jaeryang Baek 2777bab148 Merge pull request #16810 from open-webui/dev
0.6.24
2025-08-22 14:06:05 +04:00
Timothy Jaeryang Baek 4ec70e3d46 refac 2025-08-22 14:05:33 +04:00
Timothy Jaeryang Baek 43b2eca418 refac 2025-08-22 14:01:57 +04:00
Timothy Jaeryang Baek 99f7d1b62b doc: changelog 2025-08-22 13:57:37 +04:00
Timothy Jaeryang Baek 62a5db7719 chore: bump 2025-08-22 13:46:10 +04:00
Timothy Jaeryang Baek 278a4edd0b fix: tool server not loading 2025-08-22 13:44:03 +04:00
Timothy Jaeryang Baek 279e3e970f fix: redis session issue 2025-08-22 13:30:44 +04:00
Timothy Jaeryang Baek 88a5b5ff4c fix: image gen 2025-08-22 13:25:23 +04:00
Tim Jaeryang Baek c159a1a120 Merge pull request #16783 from itk-dev/feature/high-contrast-chat-messages
FEAT: highContrastMode implemented in chat messages
2025-08-22 13:22:09 +04:00
Tim Jaeryang Baek 1decefbe59 Merge pull request #16808 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-08-22 13:21:32 +04:00
Tim Jaeryang Baek 28157b4f8c Merge pull request #16802 from joaoback/patch-1
Update translation.json (pt-BR)
2025-08-22 13:21:22 +04:00
Aleix Dorca e013df19f9 Update catalan translation.json 2025-08-22 10:23:17 +02:00
joaoback 486f953ba2 Update translation.json (pt-BR)
new translations for the new items that were included in the latest versions.
2025-08-21 22:49:53 -03:00
Tim Jaeryang Baek 407dc9a401 Merge pull request #16507 from open-webui/dev
0.6.23
2025-08-21 22:21:10 +04:00
Timothy Jaeryang Baek 9fb2c9c86d refac 2025-08-21 22:02:11 +04:00
Timothy Jaeryang Baek 60b8cfb9fa refac 2025-08-21 21:48:21 +04:00
Tim Jaeryang Baek 5a66f69460 Merge pull request #16779 from mahenning/fix--clean-unload-embed/reranker-models
Fix: Free VRAM memory when updating embedding / reranking models
2025-08-21 21:38:37 +04:00
Timothy Jaeryang Baek 578f116ebb refac 2025-08-21 21:37:40 +04:00
Tim Jaeryang Baek d3b5ba8472 Merge pull request #16767 from Classic298/patch-2
Chore: 0.6.23 changelog
2025-08-21 21:28:12 +04:00
Timothy Jaeryang Baek 3e28c46026 refac 2025-08-21 21:25:42 +04:00
Tim Jaeryang Baek 71d7ba6c07 Merge pull request #16785 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve Chinese translation
2025-08-21 21:21:50 +04:00
Shirasawa 581c0f137e i18n: improve chinese translation 2025-08-21 22:01:05 +08:00
Marko Henning f2e78d7940 More formatting 2025-08-21 13:42:03 +02:00
Marko Henning c821c3ecb0 Formatting 2025-08-21 13:40:56 +02:00
Marko Henning b3de3295d6 Chage torch import to conditional import 2025-08-21 13:19:24 +02:00
Sine Jespersen 0952772705 highContrastMode implemented in chat messages 2025-08-21 12:52:32 +02:00
Classic298 c0189e43ad Update CHANGELOG.md 2025-08-21 12:15:41 +02:00
Classic298 8fad4fb264 Update CHANGELOG.md 2025-08-21 12:07:29 +02:00
Timothy Jaeryang Baek e9cab818f8 chore: dep bump 2025-08-21 14:04:17 +04:00
Timothy Jaeryang Baek 3523cea8cc chore: bump 2025-08-21 14:01:10 +04:00
Timothy Jaeryang Baek 01002042a8 refac: styling 2025-08-21 13:59:45 +04:00
Tim Jaeryang Baek f81f3b3a02 Merge pull request #16777 from itk-dev/feature/make-chat-messages-an-unordered-list
FEAT: Make chat message an unordered list
2025-08-21 13:29:27 +04:00
Timothy Jaeryang Baek 99ee1a2702 chore: format 2025-08-21 13:19:11 +04:00
Timothy Jaeryang Baek 7ede54ee0a refac 2025-08-21 13:13:38 +04:00
Tim Jaeryang Baek e96d139180 Merge pull request #16778 from itk-dev/feature/aria-hidden-img
FEAT: hide image from screen readers
2025-08-21 13:09:13 +04:00
Timothy Jaeryang Baek e6da38464b refac: ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS renamed to BYPASS_ADMIN_ACCESS_CONTROL 2025-08-21 13:08:22 +04:00
Sine Jespersen 1e9abe6d8c hide image from screen readers 2025-08-21 11:01:57 +02:00
Classic298 61af00d0a6 Update CHANGELOG.md 2025-08-21 10:55:54 +02:00
Sine Jespersen bb6864dd12 Make chat message an unordered list 2025-08-21 10:55:38 +02:00
Classic298 1404fd9fbd Update CHANGELOG.md 2025-08-21 10:53:15 +02:00
Timothy Jaeryang Baek 02479425a5 refac 2025-08-21 12:51:41 +04:00
Timothy Jaeryang Baek cd762db9bf refac 2025-08-21 12:50:11 +04:00
Marko Henning 6663fc3a6c Unloads only if internal models are used. 2025-08-21 10:49:03 +02:00
Tim Jaeryang Baek 67db71f140 Merge pull request #16770 from itk-dev/feature/remove-extra-chat-message
Remove extra chat message as it is read twice for screen readers
2025-08-21 12:47:47 +04:00
Tim Jaeryang Baek aa94359f74 Merge pull request #16772 from itk-dev/feature/h2-and-section-in-messages
add h2 and section tags to messages
2025-08-21 12:45:31 +04:00
Timothy Jaeryang Baek 9726c0b7ee refac/fix: s3 checksum validation 2025-08-21 12:44:16 +04:00
Classic298 449f0c155b Update CHANGELOG.md 2025-08-21 10:40:10 +02:00
Sine Jespersen 48f0992300 add h2 and section tags to messages 2025-08-21 10:34:46 +02:00
Tim Jaeryang Baek 492bcf9d26 Merge pull request #16769 from itk-dev/feature/missing-id-banner-iteration
fix: banner id to iteration
2025-08-21 12:34:07 +04:00
Timothy Jaeryang Baekand_00_ bbfe456f90 fix: models not loading
Co-Authored-By: _00_ <131402327+rgaricano@users.noreply.github.com>
2025-08-21 12:33:26 +04:00
Sine Jespersen 774140ae0f Remove extra chat message as it is read twice for screen readers 2025-08-21 10:16:42 +02:00
Sine Jespersen 5590ad438f add banner id to iteration 2025-08-21 09:31:57 +02:00
Classic298 9eea1aff10 0.6.23 2025-08-21 08:58:26 +02:00
Timothy Jaeryang Baek 1a15a62b73 chore: format 2025-08-21 04:47:28 +04:00
Timothy Jaeryang Baek efcf6db687 refac 2025-08-21 04:46:43 +04:00
Timothy Jaeryang Baek 66f00ce4bf refac 2025-08-21 04:40:08 +04:00
Timothy Jaeryang Baek d720e027ad enh: move chat menu 2025-08-21 04:35:27 +04:00
Timothy Jaeryang Baek f46837fd10 refac 2025-08-21 04:02:34 +04:00
Timothy Jaeryang Baek 1477f14827 refac: styling 2025-08-21 03:41:01 +04:00
Timothy Jaeryang Baek 094a16ab49 refac 2025-08-21 03:38:26 +04:00
Timothy Jaeryang Baek 86011e40be refac: account details 2025-08-21 02:39:25 +04:00
Timothy Jaeryang Baek 4451f86eb0 refac 2025-08-21 01:22:32 +04:00
Timothy Jaeryang Baek 8f51c11bc2 refac: account setting styling 2025-08-21 01:20:38 +04:00
Timothy Jaeryang Baek 01f438c3fe refac 2025-08-21 00:05:37 +04:00
Timothy Jaeryang Baek 1f91e5d3c6 fix: multi-ollama mounted display issue 2025-08-21 00:03:38 +04:00
Timothy Jaeryang BaekandAdam Outler faf01bdeac fix: None Type tool installation during startup
Co-Authored-By: Adam Outler <adamoutler@gmail.com>
2025-08-20 23:58:08 +04:00
Tim Jaeryang Baek bb21bf5239 Merge pull request #16761 from ShirasawaSama/i18n/improve-chinese-translation
i18n: Improve Chinese translation
2025-08-20 23:16:41 +04:00
Timothy Jaeryang Baek 919d65f36f feat/enh: ENABLE_OTEL_TRACES granular otel support 2025-08-20 23:03:12 +04:00
Timothy Jaeryang Baek 19e82ace23 feat: temp chat as default 2025-08-20 22:49:05 +04:00
Shirasawa 98159dbcae i18n: improve chinese translation 2025-08-21 02:42:21 +08:00
Timothy Jaeryang Baek ba972ecd52 refac 2025-08-20 21:05:04 +04:00
Tim Jaeryang Baek d2d23f4abd Merge pull request #16753 from PeterDaveHello/i18n
i18n: add missing translations
2025-08-20 21:02:17 +04:00
Peter Dave Hello 45180e829a i18n: add missing translations 2025-08-20 22:14:23 +08:00
Marko Henning cd02ff2e07 Fix if checks 2025-08-20 14:07:13 +02:00
Marko Henning 39fe385017 Correctly unloads embedding/reranker models 2025-08-20 13:30:45 +02:00
Tim Jaeryang Baek 7452b87877 Merge pull request #16741 from 0xThresh/s3vector-support
fix: batch S3 vectors in groups of 500 to comply with API limitations
2025-08-20 13:25:42 +04:00
James W. 45d9a720b9 Merge branch 'open-webui:main' into s3vector-support 2025-08-19 22:06:16 -06:00
0xThresh.eth 7fcc545672 fix: batch S3 vectors in groups of 500 to comply with API limitations 2025-08-19 22:05:47 -06:00
Timothy Jaeryang Baek 5e1f4fa0ff feat: async file upload 2025-08-20 00:36:13 +04:00
Timothy Jaeryang Baek efebf4d3a0 refac: disable rich text input in knowledge base 2025-08-20 00:33:46 +04:00
Timothy Jaeryang Baek e304ca5bd5 refac: styling 2025-08-19 23:28:05 +04:00
Timothy Jaeryang Baek 8eb5807c5f refac: images generation endpoint size field 2025-08-19 23:14:41 +04:00
Timothy Jaeryang Baek 7670543a39 refac 2025-08-19 17:20:42 +04:00
Timothy Jaeryang Baek ed926c8f26 revert 2025-08-19 17:07:24 +04:00
Timothy Jaeryang Baekanddecent-engineer-decent-datascientist b3a95f40fc refac/enh: add performance indexes
Co-Authored-By: decent-engineer-decent-datascientist <77806775+decent-engineer-decent-datascientist@users.noreply.github.com>
2025-08-19 03:24:10 +04:00
Tim Jaeryang Baek 96643f5b6d Merge pull request #14682 from olivier-lacroix/genai-tool-function
refactor: Improve tool callable generation to allow for genai native function call
2025-08-19 03:16:12 +04:00
Timothy Jaeryang Baek 575db66295 feat: save temporary chats 2025-08-19 02:37:18 +04:00
Timothy Jaeryang Baek 47ec443728 refac: temp chat button location 2025-08-19 02:03:43 +04:00
Timothy Jaeryang Baek d6f709574e refac/enh: async process chat handling 2025-08-19 01:24:53 +04:00
Timothy Jaeryang Baek 4bc77b544e refac 2025-08-18 23:39:14 +04:00
Timothy Jaeryang Baek be93081bd7 refac: only generate tags when content is non-empty 2025-08-18 23:36:11 +04:00
Timothy Jaeryang Baek 70d0477418 refac: tool name collision handling 2025-08-18 21:28:28 +04:00
Timothy Jaeryang Baek f592748011 refac: tool server redis cache 2025-08-18 20:53:46 +04:00
Timothy Jaeryang Baek 8a157578f4 enh/refac: ability to specify external tool server id 2025-08-18 20:38:55 +04:00
Timothy Jaeryang Baek 8a1f321fd4 refac: refresh tool list on show 2025-08-18 20:13:09 +04:00
Timothy Jaeryang Baek f97f21bf3a refac/fix: rename WEB_SEARCH_CONCURRENT_REQUESTS to WEB_LOADER_CONCURRENT_REQUESTS 2025-08-18 20:06:36 +04:00
Tim Jaeryang Baek a651598d19 Merge pull request #16712 from ShirasawaSama/feat--dynamic-loading-of-kokoro-js
feat: dynamic loading of kokoro-js
2025-08-18 19:50:10 +04:00
Timothy Jaeryang Baek 68d42ef850 refac 2025-08-18 19:49:29 +04:00
Tim Jaeryang Baek 979ec71522 Merge pull request #16711 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve zh-TW translation
2025-08-18 19:46:17 +04:00
Timothy Jaeryang Baek 6d0f757848 refac: openai model list 2025-08-18 19:45:26 +04:00
Shirasawa dc9445c4ec feat: dynamic loading of kokorojs 2025-08-18 23:43:54 +08:00
Timothy Jaeryang Baek 901c997054 refac 2025-08-18 19:43:18 +04:00
Shirasawa 99b88c3024 i18n: improve zh-tw translation 2025-08-18 23:37:07 +08:00
Timothy Jaeryang Baek 094a82b264 refac: follow up prompt template 2025-08-18 15:53:44 +04:00
Tim Jaeryang Baek 99604fd644 Merge pull request #16683 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve zh-cn translation
2025-08-18 14:36:01 +04:00
Shirasawa 33ecc405cb i18n: improve zh-cn translation 2025-08-18 07:27:21 +00:00
Shirasawa ad49435a94 i18n: fix typo 2025-08-18 03:54:06 +00:00
Shirasawa 282a884fad i18n: improve zh-cn translation 2025-08-17 15:13:02 +08:00
Timothy Jaeryang Baek d7363fd65f fix: arena model selected model id 2025-08-17 04:55:45 +04:00
Timothy Jaeryang Baek f23eb2a31c refac: audio lang fallback logic 2025-08-17 04:33:42 +04:00
Timothy Jaeryang Baek ccd2a0be5b refac 2025-08-17 04:15:13 +04:00
Tim Jaeryang Baek 47560d4d72 Merge pull request #14703 from rragundez/code-interpreter-blacklist
feat: Blacklist modules from arbitrary code execution in code interpreter
2025-08-17 04:12:12 +04:00
Timothy Jaeryang Baek 7f0c50f445 refac 2025-08-17 04:06:16 +04:00
Timothy Jaeryang Baek bed6aa63e1 refac 2025-08-17 03:59:00 +04:00
Tim Jaeryang Baek 6a109e972e Merge pull request #15863 from tcx4c70/feat/sqlite-wal
perf(db): Improve performance of db, especially sqlite
2025-08-17 03:55:59 +04:00
Timothy Jaeryang Baek 2387877dae refac/fix: model name retrieval edge case 2025-08-17 03:50:55 +04:00
Timothy Jaeryang Baek 40f60c163d fix: her theme 2025-08-17 03:43:39 +04:00
Timothy Jaeryang Baek 28c1de237b fix: missing banner type 2025-08-17 03:31:50 +04:00
Timothy Jaeryang Baek 04bd09d027 refac: styling 2025-08-17 03:24:05 +04:00
Timothy Jaeryang Baek 35e8c77025 refac/fix 2025-08-17 03:21:14 +04:00
Tim Jaeryang Baek ffd9e9cbe7 Merge pull request #16654 from sihyeonn/fix/sh-handling-last-delta-data
fix(utils/middleware): flush pending chat deltas on stream termination
2025-08-17 00:04:24 +04:00
Tim Jaeryang Baek 8f5843c8bc Merge pull request #16647 from BoFFire/dev
i18n: update kabyle translation
2025-08-17 00:00:04 +04:00
Tim Jaeryang Baek 397421ce0b Merge pull request #16623 from Classic298/patch-1
Small DE-de updates
2025-08-16 23:59:53 +04:00
Tim Jaeryang Baek 0dc6220ae6 Merge pull request #16653 from FukkenShit/ios-overscroll-fix
fix: disabled overscroll for iOS frontend
2025-08-16 23:59:42 +04:00
Tim Jaeryang Baek 8ae99ad7bb Merge pull request #16665 from silentoplayz/valves-fix
fix: revert accidental change
2025-08-16 22:43:44 +04:00
silentoplayz fe4b7621b3 fix: revert accidental change 2025-08-16 02:26:51 -04:00
Sihyeon Jang 3da22af859 fix(utils/middleware): flush pending chat deltas on stream termination
Guarantees the last partial delta chunk is emitted when the SSE stream closes (EOF, break, or `[DONE]` sentinel).

* Buffer `last_delta_data` and track `delta_count`
* Flush automatically once `delta_count >= chunk_size`
* Perform a final flush after the iterator ends

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-08-16 13:03:57 +09:00
FukkenShit cb1445086e fixed ios overscroll 2025-08-16 04:50:45 +04:00
ButterflyOfFire 9ba424e18a i18n: update kabyle translation 2025-08-15 18:16:50 +01:00
Tim Jaeryang Baek dfc9412117 Merge pull request #16622 from SebLz/fix/arbitrary-uid
Fix/arbitrary uid
2025-08-15 14:55:40 +04:00
Timothy Jaeryang Baek 43aa23ea77 refac 2025-08-15 14:15:27 +04:00
Tim Jaeryang Baek 286896b941 Merge pull request #16641 from silentoplayz/fix-alembic-migration-fails
fix: Improve path resolution and Alembic configuration (also a fix)
2025-08-15 14:14:34 +04:00
silentoplayz b810868239 fix: Alembic CLI commands from failing 2025-08-15 04:17:47 -04:00
Tim Jaeryang Baek 0b59aa940e Merge pull request #16606 from Rain6435/fix/azure-postgresql-pgvector-permissions
fix: resolve Azure PostgreSQL pgvector extension permission issue
2025-08-15 00:59:04 +04:00
Timothy Jaeryang Baek f1c28455ad refac: async webhook request 2025-08-15 00:07:02 +04:00
Timothy Jaeryang Baek daa4b3284f refac/fix: OAUTH_GROUPS_CLAIM env var 2025-08-14 23:59:54 +04:00
Tim Jaeryang Baek 72023f4d03 Merge pull request #16620 from FrameXX/improve-cs-translation
i18n: Improve Czech translation
2025-08-14 23:48:32 +04:00
Classic298 313c54a4d1 Small DE-de updates 2025-08-14 16:13:39 +02:00
LIESLEN c82183f985 Remove CI workflow (not part of PR) 2025-08-14 15:56:47 +02:00
Jiří Král 84fe62d557 Improve Czech translation 2025-08-14 15:11:30 +02:00
Tim Jaeryang Baek 43234c3c4d Merge pull request #16619 from ShirasawaSama/patch-4
fix: fix the Enter issue of Chinese input (fix #16615)
2025-08-14 17:03:09 +04:00
Timothy Jaeryang Baek 9e2d8470d4 refac 2025-08-14 17:01:31 +04:00
Timothy Jaeryang Baek 057533f9cc refac 2025-08-14 16:56:08 +04:00
Shirasawa e277dba8fe fix: fix the Enter issue of Chinese input 2025-08-14 12:45:58 +00:00
LIESLEN 4525ac687b feat: add Docker publish workflow for multi-architecture builds
fix: Dockerfile: make image arbitrary-UID friendly for OpenShift (group 0 + g+rwX, SGID, no fixed USER)
2025-08-14 13:54:31 +02:00
Timothy Jaeryang Baek 53f1caf91f fix: inline citations click issue 2025-08-14 14:33:49 +04:00
Timothy Jaeryang Baek 7dd5c957cf refac 2025-08-14 13:31:04 +04:00
Rain6435 a1e62ab422 fix: Formatting 2025-08-14 01:50:57 -04:00
Rain6435 1a42e96a3b fix: resolve Azure PostgreSQL pgvector extension permission issue
Replace direct CREATE EXTENSION commands with conditional checks to avoid
  permission errors on Azure PostgreSQL Flexible Server where only
  azure_pg_admin members can create extensions.

  - Check pg_extension table before attempting to create vector extension
  - Apply same fix to pgcrypto extension for consistency
  - Allows following least privilege principle for database users

  Fixes #12453
2025-08-14 01:45:02 -04:00
Timothy Jaeryang Baek 13fe78428b refac 2025-08-14 04:50:33 +04:00
Tim Jaeryang Baek 50705f926e Merge pull request #16601 from silentoplayz/i18n-updates
i18n.t: internationalize more strings throughout the codebase
2025-08-14 04:28:17 +04:00
silentoplayz 709258bbb5 fix: Add missing '$' 2025-08-13 20:24:05 -04:00
Timothy Jaeryang Baek d303f0351d refac 2025-08-14 04:19:54 +04:00
silentoplayz 82ed9b0a97 i18n.t: updates 2025-08-13 20:15:16 -04:00
Timothy Jaeryang Baek e13f1738d4 refac/fix: user message toolbar display 2025-08-14 04:12:30 +04:00
Timothy Jaeryang Baek b43acc2b3d refac/fix: tag attribute handling 2025-08-14 04:04:34 +04:00
Timothy Jaeryang Baek 3e8d3b08fa refac 2025-08-14 04:04:20 +04:00
Timothy Jaeryang Baek c134de8799 refac: knowledge search fuse threshold 2025-08-14 03:52:11 +04:00
Timothy Jaeryang Baek ad98d4300b refac/fix: milvus query logic 2025-08-14 03:18:38 +04:00
Timothy Jaeryang Baek 115231c0e5 refac/fix: dockerfile ollama cache issue 2025-08-14 02:55:38 +04:00
Timothy Jaeryang Baek 72168b1586 fix: community function import 2025-08-14 02:42:28 +04:00
Timothy Jaeryang Baek 93205d4320 fix: pending user display content 2025-08-14 02:27:24 +04:00
Timothy Jaeryang Baek 64b6ac4c1f refac/fix: sortable items isseu 2025-08-14 02:21:28 +04:00
Timothy Jaeryang Baek 28faaa23c4 refac/fix: dynamic height for command options 2025-08-14 02:14:23 +04:00
Timothy Jaeryang Baek 2ed9896dea refac/fix: oauth jwt cookie 2025-08-14 02:00:38 +04:00
Timothy Jaeryang Baek 84fd0e4152 refac: styling 2025-08-13 23:10:50 +04:00
Timothy Jaeryang Baek 7fb0228ac8 refac: conditionally display update password 2025-08-13 21:58:17 +04:00
Tim Jaeryang Baek 0845b7ffe5 Merge pull request #16582 from koflerm/patch-1
Fix: Retrieve Username Claim from Userinfo Endpoint
2025-08-13 19:23:44 +04:00
Tim Jaeryang Baek 55b6a82cd5 Merge pull request #16497 from timovanasten/fix-dutch-translation
i18n: Fix dutch translation
2025-08-13 19:18:57 +04:00
Timothy Jaeryang Baek ff55ca4d75 fix: temp chat not working 2025-08-13 19:18:30 +04:00
Markus Kofler 2011e5711d Enable Retrieving Username Claim from Userinfo Endpoint 2025-08-13 16:27:25 +02:00
Timothy Jaeryang Baek e7d9755d97 refac 2025-08-13 18:18:30 +04:00
Tim Jaeryang Baek 20101579e0 Merge pull request #16498 from Classic298/fix_model_access
fix: Chat model selector bypasses ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS privacy setting
2025-08-13 18:11:46 +04:00
Tim Jaeryang Baek 8a745b9bbf Merge branch 'dev' into fix_model_access 2025-08-13 18:07:29 +04:00
Tim Jaeryang Baek 2cb87a71f7 Merge pull request #16492 from Kylapaallikko/dev
i18n: Update fi-FI translation
2025-08-13 18:03:17 +04:00
Tim Jaeryang Baek 86a3e0a0b8 Merge pull request #16503 from athoik/user_groups_nav
feat: Allow navigating to user group from user edit
2025-08-13 18:02:40 +04:00
Tim Jaeryang Baek 37b8ea3ab2 Merge pull request #16557 from ShirasawaSama/patch-3
feat: Add administrator permission control (BYPASS_MODEL_ACCESS_CONTROL) to the two remaining main APIs
2025-08-13 17:56:39 +04:00
Timothy Jaeryang Baek 4cca1829c2 refac 2025-08-13 17:49:57 +04:00
Timothy Jaeryang Baek 230e21b10c Revert "Merge pull request #16547 from Gyarbij/main"
This reverts commit dd3cd4ac4f, reversing
changes made to f0c2d47b24.
2025-08-13 17:49:19 +04:00
Tim Jaeryang Baek dd3cd4ac4f Merge pull request #16547 from Gyarbij/main
fix: improve model sorting by handling missing names
2025-08-13 17:48:46 +04:00
Tim Jaeryang Baek f0c2d47b24 Merge pull request #16563 from ShirasawaSama/i18n/imrpove-zh-tw-translation
i18n: Improve Chinese translation (zh_TW)
2025-08-13 17:39:42 +04:00
Tim Jaeryang Baek 5fa502e419 Merge pull request #16565 from silentoplayz/i18n-fix
i18n: a few localization fixes
2025-08-13 17:39:33 +04:00
Timothy Jaeryang Baek 5a2fafceae fix: releases link 2025-08-13 17:23:28 +04:00
silentoplayz b5ab9634e8 i18n.t: localization fixes 2025-08-13 07:36:45 -04:00
Shirasawa e289e02193 i18n: improve chinese translation 2025-08-13 19:00:47 +08:00
Shirasawa e966f54001 feat: Add administrator permission control to the two remaining APIs 2025-08-13 07:02:36 +00:00
Athanasios Oikonomou c11f02662f fix: use prettier to format last commit 2025-08-13 09:36:18 +03:00
Chono N 23575825a1 fix: improve model sorting by handling missing names 2025-08-12 22:27:59 +02:00
Tim Jaeryang Baek 1e67035bd3 Merge pull request #16523 from expruc/perf/hybrid_search_bm_25
perf: disable collection retrieval and bm_25 calculation if bm_25 weight is 0
2025-08-12 23:55:26 +04:00
Tim Jaeryang Baek 85077d198b Merge pull request #16537 from PeterDaveHello/i18n
Fix missing translation for system prompt placeholder
2025-08-12 23:54:47 +04:00
Timothy Jaeryang Baek 7f17b31c12 fix: temp chat indicator styling 2025-08-12 21:43:10 +04:00
Tim Jaeryang Baek 01e54dae94 Merge pull request #16519 from alpha-pet/feat-configurable-timeout-tool-calls
feat: use AIOHTTP_CLIENT_TIMEOUT for tool calls to make timeout configurable
2025-08-12 21:34:44 +04:00
Tim Jaeryang Baek 47cde38369 Merge pull request #16509 from ShirasawaSama/patch-2
i18n: Improve zh-CN translation
2025-08-12 21:33:12 +04:00
Tim Jaeryang Baek 4616997e6a Merge pull request #16502 from joaoback/patch-1
Update translation.json (pt-BR)
2025-08-12 21:32:50 +04:00
Tim Jaeryang Baek 1aba7accbf Merge pull request #16524 from BoFFire/patch-1
i18n: update kabyle translations
2025-08-12 21:23:17 +04:00
Peter Dave Hello 32753326fa i18n: Fix missing translation for system prompt placeholder 2025-08-12 22:33:50 +08:00
ButterflyOfFire 966e244e33 Merge branch 'open-webui:main' into patch-1 2025-08-12 15:32:52 +01:00
ButterflyOfFire 0c5950fb60 Using prettier with tabs 2025-08-12 14:47:11 +01:00
ButterflyOfFire 1d3c656286 i18n: update kabyle translations
Hi, Here is a 58% translation progress.
2025-08-12 14:32:14 +01:00
expruc 74b1c80132 disable collection retrieval and bm_25 calculation if bm_25 weight is 0 or less 2025-08-12 15:53:39 +03:00
Thomas Rehn 7aa41d5d0c feat: use AIOHTTP_CLIENT_TIMEOUT for tool calls to make timeout configurable 2025-08-12 12:29:02 +02:00
Shirasawa 4c30f22444 Update translation.json 2025-08-12 15:28:55 +08:00
Shirasawa 6af94ae8f3 Update translation.json 2025-08-12 14:16:26 +08:00
Shirasawa 2625cd8719 Update translation.json 2025-08-12 12:10:09 +08:00
Timothy Jaeryang Baek f91da291d9 refac: reactive user settings 2025-08-12 03:48:04 +04:00
Athanasios Oikonomou 5543f30c49 feat: Allow navigating to user group from user edit
This commit allow navigating from user edit to user group, allowing faster updates to groups.
The querystringValue function was moved to lib/utils to reuse it in multiple places.
2025-08-12 01:08:56 +03:00
joaoback 06666cb148 Update translation.json
Several improvements were made and new translations were included for those items that had not yet been translated.
2025-08-11 18:49:40 -03:00
Classic298 df314fda1d Update main.py 2025-08-11 23:41:49 +02:00
Classic298 f758bf74c2 Update main.py 2025-08-11 23:39:01 +02:00
Classic298 357b57e1d6 Update models.py 2025-08-11 23:36:48 +02:00
Timo van Asten a10e038fff Update translation.json 2025-08-11 23:23:49 +02:00
Classic298 d8c4dd6f79 Fix admin model access (#17)
* Update models.py

* Update models.py

* Update models.py

* Update ollama.py

* Update openai.py

* Update models.py

* Update openai.py

* Update ollama.py
2025-08-11 23:23:44 +02:00
Timo van Asten 7ace997c53 Update translation.json 2025-08-11 23:13:04 +02:00
Timothy Jaeryang Baek 62506b1955 fix: duckduckgo isn't duckduckgo anymore 2025-08-11 22:43:29 +04:00
Kylapaallikko 53c9c48387 Update fi-FI translation.json
Added missing translations
2025-08-11 19:11:56 +03:00
Timothy Jaeryang Baek 2df4e7207b refac 2025-08-11 17:38:00 +04:00
Timothy Jaeryang Baek f890fe6901 enh: allow plaintext for external tool servers 2025-08-11 17:36:36 +04:00
Tim Jaeryang Baek 438e5d966f Merge pull request #16456 from open-webui/dev
0.6.22
2025-08-11 17:15:28 +04:00
Timothy Jaeryang Baek 17cc3b7d72 fix: chat item generate title button 2025-08-11 17:13:22 +04:00
Timothy Jaeryang Baek d89c5b6c30 chore: format 2025-08-11 17:01:56 +04:00
Timothy Jaeryang Baek 8fd5006b6f refac 2025-08-11 17:01:18 +04:00
Timothy Jaeryang Baek 97448e25ec fix: openai error handling 2025-08-11 17:00:06 +04:00
Timothy Jaeryang Baek 8916a284bd chore: format 2025-08-11 16:43:17 +04:00
Timothy Jaeryang Baek 8e2aaaf94f chore: bump 2025-08-11 16:42:40 +04:00
Tim Jaeryang Baek e15f8b8baf Merge pull request #16458 from Classic298/patch-3
chore: 0.6.22 changelog
2025-08-11 16:37:24 +04:00
Timothy Jaeryang Baek 890691319f fix: s3vector import issue 2025-08-11 16:23:08 +04:00
Timothy Jaeryang Baek 21094ca88b fix: pinecone insert issue 2025-08-11 16:22:58 +04:00
Tim Jaeryang Baek 9aea18f90c Merge pull request #16479 from EventHorizon-AI/fix/landing-page-mode
fix: Correctly determine the Landing Page Mode
2025-08-11 16:18:55 +04:00
Timothy Jaeryang Baek 6202e09348 refac 2025-08-11 14:09:35 +04:00
EntropyYue 29ba56757d fix: Correctly determine the Landing Page Mode 2025-08-11 17:59:01 +08:00
Timothy Jaeryang Baek fbf5f2bb67 refac: disable direct connections by default to avoid confusion 2025-08-11 13:49:53 +04:00
Tim Jaeryang Baek 1134512b22 Merge pull request #16470 from ShirasawaSama/patch-1
i18n: Improve zh-CN translation
2025-08-11 13:27:35 +04:00
Shirasawa 8424acef4b Update translation.json 2025-08-11 07:35:46 +00:00
Classic298 bc9b51402f 0.6.22 changelog init 2025-08-10 23:20:17 +02:00
Timothy Jaeryang Baek 1623396086 refac: styling 2025-08-11 00:57:37 +04:00
Timothy Jaeryang Baek 29447366d1 refac: styling 2025-08-11 00:54:08 +04:00
Tim Jaeryang Baek 5941713b0c Merge pull request #16448 from BoFFire/dev
i18n add kabyle language
2025-08-11 00:49:08 +04:00
Timothy Jaeryang Baek 32a022a823 enh: v1 endpoint support 2025-08-11 00:45:59 +04:00
Timothy Jaeryang Baek 497471d16b enh: regenerate menu toggle setting 2025-08-11 00:43:41 +04:00
Timothy Jaeryang Baek fbb8c111ed refac 2025-08-11 00:39:12 +04:00
Timothy Jaeryang Baek 059cc636f6 fix: openai response propagation issue 2025-08-11 00:37:06 +04:00
ButterflyOfFire 4df4091899 Update languages.json
Adding kabyle language to the list
2025-08-10 17:40:27 +01:00
ButterflyOfFire c7753f2d9b i18n add kabyle translation
Adding 34% translation.
2025-08-10 17:39:09 +01:00
Adam Tao 7bd7559bfe refactor: format
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2025-08-10 22:28:31 +08:00
Adam Tao 635cb8e3ff perf(db): deduplicate update_user_last_active_by_id to reduce conflicts
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2025-08-10 22:28:31 +08:00
Adam Tao b23abcbfe5 feat(db): Add DATABASE_ENABLE_SQLITE_WAL to enable SQLite WAL
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2025-08-10 22:28:31 +08:00
Tim Jaeryang Baek 30d0f8b1f6 Merge pull request #16434 from open-webui/dev
0.6.21
2025-08-10 17:39:57 +04:00
Timothy Jaeryang Baek b581536a66 doc: changelog 2025-08-10 17:39:16 +04:00
Timothy Jaeryang Baek 77dcfb5062 chore: format 2025-08-10 16:54:49 +04:00
Tim Jaeryang Baek b82324b7b7 Merge pull request #16443 from rgaricano/dev_es-ES
UPD: Update es-ES Translation adapted to v0.6.20
2025-08-10 16:52:14 +04:00
Tim Jaeryang Baek e33fb2c751 Merge pull request #16442 from andrewbbaek/dev
update ko-KR translation.json
2025-08-10 16:52:00 +04:00
_00_ 1416387af1 UPD: Update es-ES Translation adapted to v0.6.20
Updated Spanish Translation adapted to v0.6.20

- Added translation for new strings
2025-08-10 14:43:24 +02:00
Andrew Baek f436f2c106 Update ko-KR translation.json 2025-08-10 16:20:29 +04:00
Andrew Baek 5229459c02 update ko-KR translation.json 2025-08-10 16:15:49 +04:00
Timothy Jaeryang Baek 662f3cd1b3 refac 2025-08-10 16:13:13 +04:00
Tim Jaeryang Baek ee60c3e92a Merge pull request #16441 from athoik/user_groups
feat: Display assigned user groups in Admin Panel
2025-08-10 16:08:29 +04:00
Timothy Jaeryang Baek 58a6f5df98 fix: sink list icon missing 2025-08-10 16:07:52 +04:00
Tim Jaeryang Baek 50f6db5ae2 Merge pull request #16437 from Classic298/patch-2
chore: 0.6.21 Changelog
2025-08-10 15:54:46 +04:00
Athanasios Oikonomou dc453efa5c feat: Display assigned user groups in Admin Panel
Description:
This PR adds the ability to view a user’s assigned groups in the Admin Panel when editing a user.

Backend Changes:
    Added a new endpoint:
    GET /api/v1/users/{user_id}/groups

        Returns the list of groups assigned to a specific user.
        Requires admin privileges.

Frontend Changes:
    Implemented getUserGroupsById API function to call the new backend endpoint, in lib/apis/users.

    Updated EditUserModal.svelte to:
        Load user groups asynchronously when the modal is opened.
        Display the groups inline in the form before the Save button.
        Show a loading state while fetching, and a “No groups assigned” message if none exist.

Result:
Admins can now see which groups a user belongs to directly from the edit user modal,
improving visibility and reducing the need to navigate away for group membership checks.
2025-08-10 14:49:01 +03:00
Classic298 ac3c271c9f Update CHANGELOG.md 2025-08-10 13:28:13 +02:00
Tim Jaeryang Baek 397c1e7684 Merge pull request #16440 from aindriu80/update-ga-string-10-august
(i18n) Updated Irish translations
2025-08-10 15:22:54 +04:00
Classic298 c8966b92f1 Update CHANGELOG.md 2025-08-10 13:19:21 +02:00
Timothy Jaeryang Baek c3ca241e57 fix: quick actions input 2025-08-10 15:10:50 +04:00
Aindriú Mac Giolla Eoin f9d829c1cd Updating Irish translations 2025-08-10 12:01:53 +01:00
Classic298 0a261b7401 Update CHANGELOG.md 2025-08-10 12:11:38 +02:00
Timothy Jaeryang Baek d5ad0ae862 refac 2025-08-10 13:49:28 +04:00
Timothy Jaeryang Baek cd778582d5 fix: undefined model_id issue 2025-08-10 13:48:12 +04:00
Tim Jaeryang Baek 25cc8049c8 Merge pull request #16424 from CERIT-SC/audit-fix
fix: Audit does not log user information
2025-08-10 13:43:09 +04:00
Tim Jaeryang Baek c8a02bf8a8 Merge pull request #16431 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-08-10 13:41:58 +04:00
Aleix Dorca e2572dca43 Update catalan translation.json 2025-08-10 09:23:33 +02:00
xhejtman c4e0051ab2 Fix audit get_current_user
get_current_user has 4 args not 3 args:
```
get_current_user(
    request: Request,
    response: Response,
    background_tasks: BackgroundTasks,
    auth_token: HTTPAuthorizationCredentials = Depends(bearer_security)
```
2025-08-10 02:10:00 +02:00
Tim Jaeryang Baek 3f35ba27fc Merge pull request #16421 from open-webui/dev
0.6.20
2025-08-10 02:59:14 +04:00
Timothy Jaeryang Baek 2fa2728dbc chore: bump 2025-08-10 02:58:03 +04:00
Timothy Jaeryang Baek 57c082c155 doc: readme 2025-08-10 02:53:40 +04:00
Timothy Jaeryang Baek 86e0898920 fix: quick actions add issue 2025-08-10 02:52:40 +04:00
Tim Jaeryang Baek 2c3655a969 Merge pull request #15909 from open-webui/dev
0.6.19
2025-08-10 02:38:48 +04:00
Timothy Jaeryang Baek bfd8c6b87e refac 2025-08-10 02:37:27 +04:00
Timothy Jaeryang Baek 0af66558ae refac 2025-08-10 02:35:41 +04:00
Timothy Jaeryang Baek 9a145c591f chore: pyproject bump 2025-08-10 02:25:40 +04:00
Timothy Jaeryang Baek d7e9b0828b refac 2025-08-10 02:17:54 +04:00
Timothy Jaeryang Baek e9c93d082e chore: format 2025-08-10 02:15:53 +04:00
Timothy Jaeryang Baek 3f7d3def02 enh: folder filter 2025-08-10 02:10:18 +04:00
Timothy Jaeryang Baek 6497b46a78 refac 2025-08-10 01:44:33 +04:00
Timothy Jaeryang Baek bd2819a96a doc: changelog 2025-08-10 01:35:43 +04:00
Timothy Jaeryang Baek d8c76e9a0c refac 2025-08-10 01:31:07 +04:00
Timothy Jaeryang Baek b1d5e3b687 refac 2025-08-10 01:29:51 +04:00
Timothy Jaeryang Baek 50181ee99e refac 2025-08-10 01:03:02 +04:00
Timothy Jaeryang Baek eac6f6be0a chore: changelog reordering 2025-08-10 00:37:12 +04:00
Tim Jaeryang Baek 72f75afb33 Merge pull request #16420 from Classic298/patch-2
Update CHANGELOG.md
2025-08-10 00:33:49 +04:00
Classic298 ca9a545a50 Update CHANGELOG.md 2025-08-09 22:26:52 +02:00
Classic298 3395a25222 Update CHANGELOG.md 2025-08-09 22:22:22 +02:00
Classic298 9b5db894d4 Update CHANGELOG.md 2025-08-09 22:11:41 +02:00
Timothy Jaeryang Baek c77b36bdcc refac 2025-08-10 00:02:58 +04:00
Timothy Jaeryang Baek 77189664c2 chore: format 2025-08-09 23:57:35 +04:00
Timothy Jaeryang Baek f85aaa4ed9 refac: sqlcipher3-wheels as optional 2025-08-09 23:56:17 +04:00
Tim Jaeryang Baek 86fa564b44 Merge pull request #16132 from rndmcnlly/feature/sqlcipher-database-encryption
feat: Implement SQLCipher support for database encryption
2025-08-09 23:55:03 +04:00
Tim Jaeryang Baek 53425ffadb Merge pull request #16419 from expruc/feat/qdrant_improvements
feat: qdrant client improvements
2025-08-09 23:52:12 +04:00
Tim Jaeryang Baek 17ca02aa6b Merge pull request #16417 from Ithanil/prevent_idle_transactions
fix: Prevent idle transactions with PGvector DB
2025-08-09 23:51:51 +04:00
Timothy Jaeryang Baek 4e9c75be50 enh: CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE 2025-08-09 23:49:56 +04:00
Timothy Jaeryang Baek a068ba88de chore: format 2025-08-09 23:47:02 +04:00
Timothy Jaeryang BaekandJan Kessler 1a93891d97 feat: stream delta chunk
Co-Authored-By: Jan Kessler <Ithanil@users.noreply.github.com>
2025-08-09 23:43:27 +04:00
expruc 8af9ad3f30 updated query function with scroll too 2025-08-09 22:04:41 +03:00
Timothy Jaeryang Baek e2b2e8b245 refac 2025-08-09 22:59:19 +04:00
Timothy Jaeryang Baek e9c2d9d035 refac: styling 2025-08-09 22:55:19 +04:00
Timothy Jaeryang Baek 98f38f00ff enh: shift unpin pinned models 2025-08-09 22:50:27 +04:00
expruc 88abd01b87 qdrant client improvements 2025-08-09 21:12:30 +03:00
Jan Kessler 3a9601c053 use .rollback() after read-only transaction on pgvector to avoid infinitely idle transactions (and errors in certain scenarios) 2025-08-09 20:09:45 +02:00
Timothy Jaeryang Baek 4485c7a5d2 refac 2025-08-09 21:38:31 +04:00
Timothy Jaeryang Baek caf8482fba refac 2025-08-09 21:34:47 +04:00
Timothy Jaeryang Baek f4d2c6027a refac 2025-08-09 21:10:12 +04:00
Timothy Jaeryang Baek 78cf70faec chore: version bump 2025-08-09 02:12:14 +04:00
Timothy Jaeryang Baek 4b41cd1891 chore: format 2025-08-09 02:05:07 +04:00
Timothy Jaeryang Baek 92f27bb21e feat/enh: regeneration options 2025-08-09 02:00:21 +04:00
Tim Jaeryang Baek 17084f629c Merge pull request #16385 from gaby/2025-08-08-13-38-31
feat: Propagate upstream OpenAI router errors
2025-08-09 00:58:14 +04:00
Timothy Jaeryang Baek c1d566bad4 enh: oauth_sub_claim 2025-08-09 00:46:14 +04:00
Timothy Jaeryang Baek e8cb57750b refac/fix: serply 2025-08-09 00:37:37 +04:00
Timothy Jaeryang Baek 736b29ddca refac 2025-08-09 00:33:41 +04:00
Timothy Jaeryang Baek f923a85f40 refac/fix: reasoning_content chunk 2025-08-09 00:31:28 +04:00
Tim Jaeryang Baek 8714df17dd Merge pull request #16381 from psy42a/patch-1
fix: failure to bind metadata variable on insert for PGVECTOR_PGCRYPTO feature returning syntax error
2025-08-09 00:26:30 +04:00
Tim Jaeryang Baek 9fbc76f4bb Merge pull request #16397 from 17jmumford/add_gpt_5_max_token_handling
fix: added gpt-5 to reasoning model payload handler
2025-08-09 00:22:30 +04:00
Tim Jaeryang Baek ea215aa8c2 Merge pull request #16396 from andrewbbaek/dev
refac: oidc provider url validation
2025-08-09 00:21:13 +04:00
Jeremy Mumford c69f2cc776 updated comments 2025-08-08 14:20:14 -06:00
Jeremy Mumford 0c40d93da4 renamed and added gpt-5 to reflect OpenAI updates 2025-08-08 14:16:28 -06:00
Andrew Baek 451b614f4d refac: oidc provider url validation 2025-08-09 00:15:19 +04:00
Timothy Jaeryang Baek 34689e37f8 refac 2025-08-08 20:58:52 +04:00
Timothy Jaeryang Baek 95f9d69e9a refac 2025-08-08 20:52:53 +04:00
Timothy Jaeryang Baek a5683c411a refac 2025-08-08 19:58:53 +04:00
Timothy Jaeryang Baek c42c1ee632 refac: styling 2025-08-08 18:48:27 +04:00
Timothy Jaeryang Baek 69f6fbe4e7 refac 2025-08-08 18:40:03 +04:00
Juan Calderon-Perez 7619f449c8 Format code base 2025-08-08 10:10:32 -04:00
Juan Calderon-Perez d2f2d42e09 Format python code 2025-08-08 10:09:31 -04:00
Juan Calderon-Perez 5d7e8c8e5f Format openai router 2025-08-08 10:04:02 -04:00
Juan Calderon-Perez 2ae7584686 feat: forward upstream OpenAI errors 2025-08-08 09:38:34 -04:00
Timothy Jaeryang Baek 9af64f0db1 refac 2025-08-08 14:27:28 +04:00
Timothy Jaeryang Baek 3f85375db2 refac: styling 2025-08-08 14:24:54 +04:00
Timothy Jaeryang Baek 2bccc7b8fc refac 2025-08-08 14:18:17 +04:00
Timothy Jaeryang Baek 9979ed4b46 refac 2025-08-08 14:07:23 +04:00
Timothy Jaeryang Baek 151c072bbc refac 2025-08-08 14:04:44 +04:00
Timothy Jaeryang Baek 81fed7291c refac 2025-08-08 13:56:34 +04:00
Timothy Jaeryang Baek f8e8d7e048 refac 2025-08-08 13:50:53 +04:00
Timothy Jaeryang Baek 056431606b enh: tool calling support in quick actions 2025-08-08 13:43:30 +04:00
Timothy Jaeryang Baek ff9f2614f3 refac: styling 2025-08-08 13:09:40 +04:00
Timothy Jaeryang Baek c92d1ce219 refac 2025-08-08 13:05:50 +04:00
Timothy Jaeryang Baek 4ba96fe3e5 refac 2025-08-08 12:55:24 +04:00
Timothy Jaeryang Baek 04f37ca279 refac 2025-08-08 12:50:21 +04:00
Timothy Jaeryang Baek 1fc40ad5ee refac 2025-08-08 12:40:13 +04:00
Timothy Jaeryang Baek 19bc5f1c34 refac 2025-08-08 12:34:27 +04:00
Timothy Jaeryang Baek bef5e6bb29 chore: boto3 bump 2025-08-08 12:26:45 +04:00
Timothy Jaeryang Baek 8b489cb31f refac: s3 vector 2025-08-08 12:24:47 +04:00
Timothy Jaeryang Baek 601f1af0a5 refac 2025-08-08 12:17:50 +04:00
Tim Jaeryang Baek 67dcef5e9a Merge pull request #16344 from Rain6435/fix-openapi-array-parameters
fix: include items property in OpenAPI array parameters
  for OpenAI function calling
2025-08-08 12:12:28 +04:00
Timothy Jaeryang Baek d2c7a60a43 refac 2025-08-08 12:07:15 +04:00
Timothy Jaeryang Baek 961df8ef3d refac 2025-08-08 11:57:51 +04:00
Timothy Jaeryang Baek d6326549d6 refac 2025-08-08 11:57:07 +04:00
Timothy Jaeryang Baek b084613004 refac: styling 2025-08-08 11:53:06 +04:00
Timothy Jaeryang Baek e2ce735d12 refac: styling 2025-08-08 04:37:08 +04:00
Timothy Jaeryang Baek 867a86b264 refac 2025-08-08 04:32:17 +04:00
Timothy Jaeryang Baek 0b68fc0a1b refac: styling 2025-08-08 04:10:58 +04:00
Timothy Jaeryang Baek d9f46baa70 refac: sidebar 2025-08-08 03:53:09 +04:00
Timothy Jaeryang Baek e6726d8444 fix: triple backtick enter rich text input 2025-08-08 02:57:44 +04:00
Tim Jaeryang Baek 55248bed2d Merge pull request #16348 from itk-dev/feature/add-label-to-settings-modal-controls
FEAT: add label to settings modal controls
2025-08-07 20:43:17 +04:00
Sine Jespersen 8d6c1c497f add aria labels to controls in interface modal 2025-08-07 12:19:27 +02:00
Rain6435 30540cb40e fix: include items property in OpenAPI array parameters for OpenAI function calling
Resolves issue where OpenAPI specs with array query parameters were generating
  invalid OpenAI function schemas missing the required 'items' property, causing
  400 Bad Request errors from OpenAI.

  The fix ensures that when converting OpenAPI parameter schemas to OpenAI function
  schemas, array parameters properly include their 'items' property definition.

  Fixes open-webui/open-webui#14115
2025-08-07 01:21:22 -04:00
Timothy Jaeryang Baek 761d439830 refac 2025-08-07 03:42:02 +04:00
Timothy Jaeryang Baek 7611441676 refac 2025-08-07 03:37:13 +04:00
Timothy Jaeryang Baek 2bfe684716 refac: styling 2025-08-07 03:35:12 +04:00
Timothy Jaeryang Baek 07b16cc8d1 refac 2025-08-07 02:53:28 +04:00
Timothy Jaeryang Baek 2eef60ced4 refac: sidebar styling 2025-08-07 02:50:05 +04:00
Timothy Jaeryang Baek 85a3c32f07 refac: styling 2025-08-07 02:47:13 +04:00
Timothy Jaeryang Baek ca213cbd12 refac 2025-08-07 02:29:54 +04:00
Timothy Jaeryang Baek 13682e28db refac 2025-08-07 02:25:52 +04:00
Timothy Jaeryang Baek db7ea6d23f refac 2025-08-07 02:20:32 +04:00
Timothy Jaeryang Baek 4a57e581d9 refac 2025-08-07 02:15:31 +04:00
Timothy Jaeryang Baek 2e733b46b0 enh: custom action buttons 2025-08-07 02:11:27 +04:00
Timothy Jaeryang Baek 5d670cb7ce refac: interface settings styling 2025-08-07 02:11:16 +04:00
Timothy Jaeryang Baek cce1f876c9 refac 2025-08-07 01:41:51 +04:00
Timothy Jaeryang Baek f47befa902 refac: styling 2025-08-07 01:22:55 +04:00
Timothy Jaeryang Baek 73c92e2e3e refac 2025-08-07 01:19:34 +04:00
Timothy Jaeryang Baek 6598812a25 refac 2025-08-07 00:14:35 +04:00
Timothy Jaeryang Baek 353ecda084 refac 2025-08-07 00:12:24 +04:00
Timothy Jaeryang Baek 4797799400 refac 2025-08-06 23:36:43 +04:00
Timothy Jaeryang Baek 93a91a678a enh: ability to switch off floating action buttons 2025-08-06 23:28:05 +04:00
Timothy Jaeryang Baek e16251c473 refac 2025-08-06 23:15:08 +04:00
Timothy Jaeryang Baek 07aafa5fea refac 2025-08-06 23:09:34 +04:00
Timothy Jaeryang Baek 0fd5881b7d refac: styling 2025-08-06 23:08:41 +04:00
Timothy Jaeryang Baek fa04711680 refac 2025-08-06 22:55:47 +04:00
Timothy Jaeryang Baek d2fa66bb9d refac: styling 2025-08-06 22:48:12 +04:00
Timothy Jaeryang Baek 8b9c5c4c1e enh: multi model response tabbed display 2025-08-06 22:47:34 +04:00
Tim Jaeryang Baek 0067cf6eff Merge pull request #16334 from leebeomhun/i18n-ko
i18n: update and fix ko-KR translation
2025-08-06 21:32:03 +04:00
Timothy Jaeryang Baek 0912a023c2 fix: jwt token exposed in url 2025-08-06 21:02:54 +04:00
Timothy Jaeryang BaekandG30 041da26756 feat: add pinned, shared and archived tags functionality for chat search moda
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2025-08-06 20:55:58 +04:00
BHLee 001d07f590 i18n update and fix:ko-KR translation.json 2025-08-07 01:29:37 +09:00
Tim Jaeryang Baek d1a654019b Merge pull request #16332 from itk-dev/feature/text-contrast-settings-modal-general
FEAT: text contrast settings modal general
2025-08-06 19:53:10 +04:00
Timothy Jaeryang Baek aae6008542 chore: bump pillow 2025-08-06 19:52:16 +04:00
Timothy Jaeryang Baek b9885529a5 refac: knowledge item modal 2025-08-06 19:50:51 +04:00
Sine Jespersen 8eee4d1c12 increase text contrast on highContrastMode 2025-08-06 15:49:20 +02:00
Tim Jaeryang Baek 8e25eb104b Merge pull request #16309 from jayteaftw/fips-compatible
fix: Resolved FATAL FIPS SELFTEST FAILURE
2025-08-06 15:14:12 +04:00
Tim Jaeryang Baek 91952096c2 Merge pull request #16321 from itk-dev/feature/more-accessible-menu
Feat: enhance accessibility of menu
2025-08-06 15:13:13 +04:00
Timothy Jaeryang Baek 80dbd76d92 enh: image compression in channels 2025-08-06 15:11:43 +04:00
Timothy Jaeryang Baek 2f349b5979 refac 2025-08-06 15:06:43 +04:00
Timothy Jaeryang Baek f2cae3d0a7 refac 2025-08-06 15:02:39 +04:00
Timothy Jaeryang Baek 4cc2c1915e refac 2025-08-06 14:47:27 +04:00
Timothy Jaeryang Baek 52b2158c67 refac 2025-08-06 14:42:57 +04:00
Tim Jaeryang Baek 70eb83b701 Merge pull request #16185 from hiwylee/vector-search-branch
feat: oracle 23ai Vector search for new supported vector db
2025-08-06 14:36:14 +04:00
Tim Jaeryang Baek 1ba8c3389e Merge pull request #16284 from silentoplayz/fix-clone-chat-tags
fix: also clone the chat's tags, folder stats, and pinned status when cloning a chat
2025-08-06 14:33:04 +04:00
Sine Jespersen 52ef3e74e6 add aria-labels and aria-pressed to enhance accessibility 2025-08-06 12:29:50 +02:00
Sine Jespersen 9df790a9f9 make buttons DropdownMenuItems to make them accessible by keyboard, add handling of highContrastMode 2025-08-06 12:29:28 +02:00
Sine Jespersen ba1e32f60d add aria-label to labelless button to enhance accessibility 2025-08-06 12:28:42 +02:00
Timothy Jaeryang Baek 5d4199bf52 chore: format 2025-08-06 14:27:58 +04:00
Timothy Jaeryang Baek 428db553e2 refac 2025-08-06 14:27:07 +04:00
Timothy Jaeryang Baek 7fffecd168 refac 2025-08-06 14:26:22 +04:00
Timothy Jaeryang Baek 6c06024cf9 refac: scim 2025-08-06 14:25:42 +04:00
Tim Jaeryang Baek f0e1c3f540 Merge pull request #15694 from dieu-bis/feat/scim-2.0-support
FEAT: Add SCIM 2.0 support for automated user provisioning
2025-08-06 14:22:48 +04:00
Sine Jespersen 502a36869e aria-hidden on icons to hide them from assistive technology 2025-08-06 12:17:44 +02:00
Timothy Jaeryang Baek 5ae0036265 refac 2025-08-06 14:03:40 +04:00
Timothy Jaeryang Baek 3a1bbcb12e refac 2025-08-06 13:48:43 +04:00
Timothy Jaeryang Baek 2e36540023 refac: tag handling 2025-08-06 13:32:28 +04:00
Timothy Jaeryang Baek e9fb161bcd refac 2025-08-06 12:36:35 +04:00
Timothy Jaeryang Baek bfa42c6277 enh: formatting toolbar for chat 2025-08-06 12:21:18 +04:00
Timothy Jaeryang Baek 0acd78049d refac: hide docs, releases for users 2025-08-06 11:50:35 +04:00
jayteaftw 4079ac55d8 Set av==14.0.1 2025-08-05 15:29:40 -07:00
Timothy Jaeryang BaekandClassic298 55ad48d1c3 feat: ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-08-06 01:44:52 +04:00
silentoplayz 7c4550fa92 fix: cloned chat metadata not taken into consideration 2025-08-05 16:21:03 -04:00
Timothy Jaeryang Baek 182192f3c9 chore: analytics scaffolding 2025-08-05 22:28:16 +04:00
Timothy Jaeryang Baek d0657054a5 enh: abililty to stop merge response 2025-08-05 22:25:51 +04:00
Timothy Jaeryang Baek 8b46a9010e refac: styling 2025-08-05 22:22:49 +04:00
Timothy Jaeryang Baekandpickle-dice f24b76d9a3 refac: has_users
Co-Authored-By: pickle-dice <159401444+hassan-ajek@users.noreply.github.com>
2025-08-05 22:15:22 +04:00
silentoplayz bd570b7e2e fix: chat metadata wasn't being taken into consideration when cloning a chat
fix: chat metadata wasn't being taken into consideration when cloning a chat
2025-08-05 13:43:29 -04:00
Timothy Jaeryang Baek 53a328d00b refac: styling 2025-08-05 20:31:10 +04:00
Tim Jaeryang Baek f81964b412 Merge pull request #16295 from Jakobu5/cors-custom-scheme-patch
feat: add custom cors scheme option
2025-08-05 20:03:32 +04:00
Tim Jaeryang Baek 40c525b7b9 Merge pull request #16296 from itk-dev/feature/accessible-banner
feat: translate elements in banner for accessibility
2025-08-05 20:02:49 +04:00
psy42a f3b0f7d358 Fix syntax error where the previous use of :metadata::text in some sqlachamy/postgres versions doesn't bind at all
Fix syntax error where the previous use of :metadata::text in some sqlachamy/postgres versions doesn't bind the variable at all
2025-08-05 23:27:50 +10:00
Sine Jespersen 3494fa8707 aria hidden on svgs to hide them from assistive tech 2025-08-05 12:15:55 +02:00
Sine Jespersen e2b722deb2 translate elements in banner for accessibility 2025-08-05 11:18:56 +02:00
Jakob Hagl ae2a746d0c add custom cors scheme option 2025-08-05 10:38:08 +02:00
hiwylee 797822a3a3 add oracledb only
>      "moto[s3]>=5.0.26",
-
+    "oracledb>=3.2.0",
2025-08-04 16:03:29 +00:00
Tim Jaeryang Baek 66341124a6 Merge pull request #16009 from Classic298/patch-1
feat: add OAuth configuration warning for missing OPENID_PROVIDER_URL
2025-08-04 18:31:32 +04:00
Tim Jaeryang Baek 0b627248f5 Merge pull request #15640 from ipapapa/feat/cleaner-logs
refactor(logger): Conditionally include extra_json in logs
2025-08-04 17:52:12 +04:00
Timothy Jaeryang Baek 985e54b662 refac: styling 2025-08-04 17:51:22 +04:00
Tim Jaeryang Baek f41a8e67e8 Merge pull request #15705 from rgaricano/dev-HybridSearch-bm25_slider
FEAT: STYLE: Frontend HybridSearch-BM25 Slider
2025-08-04 17:48:24 +04:00
Timothy Jaeryang Baek 3f3a69568f enh: ability to sort folder chats 2025-08-04 17:37:33 +04:00
Tim Jaeryang Baek ae2de6b09c Merge pull request #16249 from silentoplayz/fix-richtext-getwordatdocpos
fix(frontend): Attempt to resolve TypeError in RichTextInput.svelte
2025-08-04 17:31:52 +04:00
Timothy Jaeryang Baek 173fa62af3 refac 2025-08-04 17:28:26 +04:00
Timothy Jaeryang Baek 88ff0b952e refac/fix: table markdown input 2025-08-04 17:27:28 +04:00
silentoplayz 55d3c07d85 revert: Reverts requested 2025-08-04 08:42:48 -04:00
Timothy Jaeryang Baek 2d2fbdb341 fix: styling 2025-08-04 16:02:48 +04:00
Timothy Jaeryang Baek e262069265 refac/fix: folder direct file upload 2025-08-04 15:33:44 +04:00
Timothy Jaeryang Baek 4c9ab7bd50 refac 2025-08-04 15:30:09 +04:00
Timothy Jaeryang Baek e8696c63fe refac 2025-08-04 15:23:43 +04:00
Tim Jaeryang Baek 5db60ca34f Merge pull request #15903 from Hisma/marker-api-update
feat: Add configurable API URL (for self-hosting) and additional_config parameter for Datalab Marker API
2025-08-04 15:21:03 +04:00
Timothy Jaeryang Baek 60c5ea1bcc enh: include chat id in feedback modal 2025-08-04 15:15:31 +04:00
Timothy Jaeryang Baek 7aeca7dee2 refac 2025-08-04 15:12:39 +04:00
Timothy Jaeryang Baek 8562e3e438 refac: styling 2025-08-04 15:04:16 +04:00
Tim Jaeryang Baek 6ce1166b98 Merge pull request #16033 from silentoplayz/sticky-search
refac: sticky headers & smarter pagination on users list page
2025-08-04 15:03:02 +04:00
Timothy Jaeryang Baek 35400daf19 enh/refac: redis cluster support 2025-08-04 14:15:08 +04:00
Tim Jaeryang Baek 01320d99d6 Merge pull request #16246 from silentoplayz/sensitiveinput-password-fields
feat: Add password visibility toggle to password fields with SensitiveInput.svelte component
2025-08-04 10:51:39 +04:00
silentoplayz 7a80e60785 fix(frontend): Attempt to resolve TypeError in RichTextInput.svelte
Fixes an issue where `ue.getWordAtDocPos is not a function` would be thrown in `MessageInput.svelte`.

The error was caused by a timing issue where the `getWordAtDocPos` method on the `RichTextInput` component was not available when called from an event handler within the same component.

This change refactors the code to pass the `getWordAtDocPos` function as a callback prop from `RichTextInput` to `MessageInput`, ensuring it's available when needed.
2025-08-03 22:36:08 -04:00
silentoplayz 56eeed6277 feat: Add password visibility toggle to password fields w SensitiveInput.svelte component 2025-08-03 16:07:12 -04:00
Tim Jaeryang Baek 7c29084a19 Merge pull request #16237 from expruc/chore/separate_otel_logs_config
chore: separate otel logs config
2025-08-03 21:08:55 +04:00
expruc 58180c0586 added otel lgos specific config 2025-08-02 22:15:22 +03:00
Timothy Jaeryang Baek 00084c6ca6 refac/fix: tool payload 2025-08-02 19:36:46 +04:00
Tim Jaeryang Baek f47a100730 Merge pull request #16231 from hadadarjt/dev
UserMenu: Fix quote typo in docs link.
2025-08-02 19:06:57 +04:00
Tim Jaeryang Baek a1fac8fc7f Merge pull request #16233 from andrewbbaek/dev
fix: add conditional password confirmation on signup
2025-08-02 19:06:14 +04:00
Andrew Baek c1ba289ab5 fix: add conditional password confirmation on signup
Password confirmation during signup is now only enforced if the 'enable_signup_password_confirmation' feature flag is enabled in the config. This allows for more flexible signup flows based on configuration.
2025-08-02 19:04:52 +04:00
Hadad ab140ea531 UserMenu: Fix quote typo in docs link.
* Docs link in the user menu are invalid when clicked via browser.

Signed-off-by: Hadad <hadad@linuxmail.org>
2025-08-02 20:21:41 +07:00
Tim Jaeryang Baek 09e475f6cc Merge pull request #16192 from expruc/feat/otel-logger-handler
feat: otel logger handler
2025-08-02 14:52:29 +04:00
Tim Jaeryang Baek 49926f06ee Merge branch 'dev' into feat/otel-logger-handler 2025-08-02 14:52:16 +04:00
Tim Jaeryang Baek e988750272 Merge pull request #16194 from ahangarha/patch-1
i18n:update fa translation
2025-08-02 14:49:47 +04:00
Tim Jaeryang Baek bf4e13e144 Merge pull request #16197 from open-webui/dependabot/pip/backend/dev/authlib-1.6.1
chore(deps): bump authlib from 1.4.1 to 1.6.1 in /backend
2025-08-02 14:01:11 +04:00
Tim Jaeryang Baek 9992dc98bb Merge pull request #16198 from open-webui/dependabot/pip/backend/dev/markdown-3.8.2
chore(deps): bump markdown from 3.7 to 3.8.2 in /backend
2025-08-02 14:01:04 +04:00
Tim Jaeryang Baek ea38d415ce Merge pull request #16199 from open-webui/dependabot/pip/backend/dev/fake-useragent-2.2.0
chore(deps): bump fake-useragent from 2.1.0 to 2.2.0 in /backend
2025-08-02 14:00:59 +04:00
Tim Jaeryang Baek 5855517a46 Merge pull request #16200 from open-webui/dependabot/pip/backend/dev/aiohttp-3.12.15
chore(deps): bump aiohttp from 3.11.11 to 3.12.15 in /backend
2025-08-02 14:00:53 +04:00
Tim Jaeryang Baek cf00df3b96 Merge pull request #16201 from open-webui/dependabot/pip/backend/dev/google-genai-1.28.0
chore(deps): bump google-genai from 1.15.0 to 1.28.0 in /backend
2025-08-02 14:00:48 +04:00
Timothy Jaeryang Baek 446f4ee5a8 refac 2025-08-02 13:59:07 +04:00
Tim Jaeryang Baek 093acb3724 Merge pull request #16222 from expruc/feat/seperate_otel_metrics_config
feat: separate otel metrics configuration
2025-08-02 13:57:10 +04:00
expruc a679fb3f45 split otel metrics from general otel configuration 2025-08-02 11:30:34 +03:00
Timothy Jaeryang Baek 994170b482 refac 2025-08-01 18:17:09 +04:00
Timothy Jaeryang Baek d4f70830d1 refac: markdown br rendering 2025-08-01 14:07:11 +04:00
dependabot[bot] 3098471a8a chore(deps): bump google-genai from 1.15.0 to 1.28.0 in /backend
Bumps [google-genai](https://github.com/googleapis/python-genai) from 1.15.0 to 1.28.0.
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v1.15.0...v1.28.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 02:27:15 +00:00
dependabot[bot] fce486fe30 chore(deps): bump aiohttp from 3.11.11 to 3.12.15 in /backend
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.15
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 02:26:36 +00:00
dependabot[bot] aee68aedb2 chore(deps): bump fake-useragent from 2.1.0 to 2.2.0 in /backend
Bumps [fake-useragent](https://github.com/fake-useragent/fake-useragent) from 2.1.0 to 2.2.0.
- [Release notes](https://github.com/fake-useragent/fake-useragent/releases)
- [Commits](https://github.com/fake-useragent/fake-useragent/compare/2.1.0...2.2.0)

---
updated-dependencies:
- dependency-name: fake-useragent
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 02:26:25 +00:00
dependabot[bot] 4b0bf3130b chore(deps): bump markdown from 3.7 to 3.8.2 in /backend
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.7 to 3.8.2.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.7...3.8.2)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 02:25:33 +00:00
dependabot[bot] 293cdb810e chore(deps): bump authlib from 1.4.1 to 1.6.1 in /backend
Bumps [authlib](https://github.com/authlib/authlib) from 1.4.1 to 1.6.1.
- [Release notes](https://github.com/authlib/authlib/releases)
- [Changelog](https://github.com/authlib/authlib/blob/main/docs/changelog.rst)
- [Commits](https://github.com/authlib/authlib/compare/v1.4.1...v1.6.1)

---
updated-dependencies:
- dependency-name: authlib
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 02:25:17 +00:00
Adam M. Smith 414d026d59 fix: swap sqlcipher dependency to sqlcipher3-wheels 2025-07-31 23:21:35 +00:00
Adam M. Smith c9a4bc18f4 feat: Implement SQLCipher support for database encryption
- Added sqlcipher3 dependency to requirements.txt for SQLCipher integration.
- Modified database connection handling in wrappers.py to support encrypted SQLite databases using the new sqlite+sqlcipher:// URL protocol.
- Updated db.py to handle SQLCipher URLs for SQLAlchemy connections.
- Enhanced Alembic migration environment to support SQLCipher URLs.
2025-07-31 23:21:35 +00:00
Mostafa Ahangarha 4e64c3eca9 Update fa translation
Fix a mistake in translation
2025-08-01 00:33:09 +03:30
hiwylee bd215a1b96 Merge branch 'dev' into vector-search-branch 2025-08-01 04:23:38 +09:00
expruc 2035eabb1f added otel logging handler 2025-07-31 21:58:49 +03:00
hiwylee 0e640dd71e resolve conflict 2025-08-01 02:58:51 +09:00
Timothy Jaeryang Baek d2a3aacaba refac: styling 2025-07-31 19:00:43 +04:00
Timothy Jaeryang Baek 56af3e7357 refac: granular chat controls permissions 2025-07-31 18:59:37 +04:00
Timothy Jaeryang Baek 708e0ed05e refac/enh: chat controls permissions 2025-07-31 18:58:58 +04:00
Timothy Jaeryang Baek 84289b9a8d refac 2025-07-31 18:45:39 +04:00
Tim Jaeryang Baek b378ac1f57 Merge pull request #16186 from itk-dev/feature/user-menu-tab-order-and-menu-item-accessibility
FEAT: user menu tab order and menu item accessibility
2025-07-31 17:48:17 +04:00
Timothy Jaeryang Baek 6a17ba5b7a refac: metadata handling in vectordb 2025-07-31 17:45:06 +04:00
Sine Jespersen 3dc23368aa use bits-ui dropdownmenu item to make menu items focusable 2025-07-31 15:33:27 +02:00
Timothy Jaeryang Baek aa83ebae58 refac: lazySpanExporter no longer needed 2025-07-31 17:30:37 +04:00
Timothy Jaeryang Baek cb487d3f61 chore: otel bump 2025-07-31 17:19:37 +04:00
Timothy Jaeryang Baek 6b34b2c946 refac 2025-07-31 16:47:02 +04:00
Tim Jaeryang Baek 1f22e1d84c Merge pull request #16096 from gkkachi/dev
fix: properly handle full URLs in form_data.path
2025-07-31 16:45:48 +04:00
Timothy Jaeryang Baek b510f21a5b feat: signup confirm password 2025-07-31 16:43:37 +04:00
hiwylee 6b639cd42b Ignore yarnlock 2025-07-31 21:39:03 +09:00
Timothy Jaeryang Baek 1c83f48c45 refac 2025-07-31 16:27:20 +04:00
Timothy Jaeryang Baek d3547f0f54 refac: error handling 2025-07-31 16:17:33 +04:00
Timothy Jaeryang Baek 73b07df28f enh/refac: debounce chat draft save 2025-07-31 16:02:43 +04:00
Timothy Jaeryang Baek 05895d9657 refac/enh: allow underscore for prompt command 2025-07-31 15:55:10 +04:00
Tim Jaeryang Baek f1300a8fd9 Merge pull request #16178 from itk-dev/feature/hide-decorative-images-from-assistive-tech
Feature/hide decorative images from assistive tech
2025-07-31 15:30:20 +04:00
Tim Jaeryang Baek 22f9481e99 Merge pull request #16176 from itk-dev/feature/toggle-user-assistant-button-accessibility
FEAT: toggle user assistant button accessibility
2025-07-31 15:26:41 +04:00
Sine Jespersen 6434f1ec99 remove alt text on image for accessibility 2025-07-31 13:25:14 +02:00
Sine Jespersen eac359d6b4 aria-hidden on icon for accessibility 2025-07-31 13:25:07 +02:00
Sine Jespersen 343e41886f add aria-label and aria-pressed to toggle button for accessibility 2025-07-31 13:14:53 +02:00
Sine Jespersen 77babb6b63 add aria label, make alt text translatable 2025-07-31 10:32:32 +02:00
Sine Jespersen 30218a6db0 remove duplicate button in user menu to improve tab order 2025-07-31 10:32:15 +02:00
Timothy Jaeryang Baek 0763d8e9b9 refac: message input styling 2025-07-30 14:21:18 +04:00
Timothy Jaeryang Baek f027df06d1 fix: leaderboard loading issue 2025-07-30 14:18:36 +04:00
Timothy Jaeryang Baek 3ab10c6758 enh: follow up hover tooltip 2025-07-30 14:14:02 +04:00
Timothy Jaeryang Baek 73a38aca72 refac 2025-07-30 14:09:33 +04:00
Timothy Jaeryang Baek 22d98e6ee5 fix: playground styling 2025-07-30 14:06:42 +04:00
Tim Jaeryang Baek 00a00f5162 Merge pull request #16063 from silentoplayz/fix-her-theme
fix(frontend): Ensure 'Her' theme is always a light theme
2025-07-30 13:01:31 +04:00
Tim Jaeryang Baek 47d86d3c2f Merge pull request #16061 from silentoplayz/fix/autoscrolling-in-FloatingButtons
fix: prevent error when autoscrolling in FloatingButtons
2025-07-30 13:01:02 +04:00
Tim Jaeryang Baek 4cccb097f9 Merge pull request #16131 from open-webui/main
refac
2025-07-29 23:46:00 +04:00
Timothy Jaeryang Baek b8da4a8cd8 refac 2025-07-29 23:45:25 +04:00
silentoplayz 694494ad5e Revert "fix: should fix the "breaking change""
This reverts commit f1de837fef.
2025-07-29 09:33:17 -04:00
silentoplayz f1de837fef fix: should fix the "breaking change" 2025-07-29 09:29:26 -04:00
Timothy Jaeryang Baek 071a2ac835 fix: python format issue 2025-07-29 17:08:47 +04:00
Tim Jaeryang Baek 526a98171e Merge pull request #16077 from silentoplayz/delete-duplicate-SVGs
chore: Remove `ChatBubbleOvalEllipsis` and `Download` icon components (duplicates of `ChatBubble` and `ArrowDownTray`)
2025-07-29 16:53:42 +04:00
Tim Jaeryang Baek 1339d0059a Merge pull request #16123 from ShirasawaSama/locale_improve_chinese_i18n
i18n: improve chinese
2025-07-29 16:42:25 +04:00
Shirasawa ad785c46dc Update translation.json 2025-07-29 20:38:00 +08:00
Shirasawa 57452ad49e i18n: improve zh-CN 2025-07-29 20:35:14 +08:00
Tim Jaeryang Baek 97a788e4a3 Merge pull request #16109 from amoshydra/pwa-remove-orientation-member
fix: remove orientation specifier, respect OS level rotation
2025-07-29 16:24:24 +04:00
Tim Jaeryang Baek fd9b6d1193 Merge pull request #16117 from silentoplayz/fix-autocomplete-toggle
fix: "Autocomplete Generation" toggle would appear enabled breifly, even when disabled
2025-07-29 16:23:31 +04:00
Timothy Jaeryang Baek 1cbbdf7771 fix: user menu buttons 2025-07-29 16:15:23 +04:00
silentoplayz a63b447992 fix-autocomplete-toggle 2025-07-29 06:35:54 -04:00
Tim Jaeryang Baek cd6eb9a9e2 Merge pull request #16115 from silentoplayz/draggable-pinned-models
feat: allow draggable reorganization of pinned models in chat sidebar
2025-07-29 12:56:06 +04:00
Tim Jaeryang Baek b8edcd41f3 Merge pull request #16110 from qingchunnh/Update_zh-CN-25729
i18n: Improve zh-CN
2025-07-29 12:54:59 +04:00
silentoplayz 3243344a85 feat: allow draggable reorganization of pinned models 2025-07-29 04:37:29 -04:00
Timothy Jaeryang Baek 22d53be3a5 refac 2025-07-29 11:22:14 +04:00
qingchun bf0e7651fa i18n: Improve zh-CN 2025-07-29 12:21:27 +08:00
amoshydra 56a094a34a fix(pwa): remove orientation specifier, respect OS level rotation
Related to #13949
2025-07-29 05:48:15 +08:00
Timothy Jaeryang Baek 807c34b40c fix 2025-07-29 00:51:23 +04:00
Timothy Jaeryang Baek b2a04bac03 refac 2025-07-28 18:44:27 +04:00
Konosuke Kachi 0a8f482264 fix: properly handle full URLs in form_data.path 2025-07-28 23:34:23 +09:00
Timothy Jaeryang BaekandG30 f2da65b778 fix: hide community sharing button when disabled
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2025-07-28 17:42:45 +04:00
Tim Jaeryang Baek 58dbd869ba Merge pull request #16071 from GSAlex/main
fix: Render template variables in folder system prompts
2025-07-28 17:40:47 +04:00
Tim Jaeryang Baek 49a928d48c Merge pull request #16048 from silentoplayz/discovery-links-adjustment
chore: update discover links
2025-07-28 17:37:40 +04:00
Timothy Jaeryang Baek bcfb4d1e43 refac 2025-07-28 13:12:38 +04:00
Timothy Jaeryang Baek c1e4139e5c feat: model sync endpoint 2025-07-28 13:06:05 +04:00
Timothy Jaeryang Baek 49a6211d36 refac 2025-07-28 11:31:06 +04:00
Timothy Jaeryang Baek 0c5fbdedd8 refac 2025-07-28 11:09:48 +04:00
silentoplayz 84e568f902 remove: ArrowRightTag.svelte and used a transform 2025-07-27 20:43:17 -04:00
silentoplayz b633cb7c24 fix: last commit here, I swear! 2025-07-27 20:23:49 -04:00
silentoplayz eeccf23e4d fix: I thought this got pushed 2025-07-27 20:23:06 -04:00
silentoplayz a64b91e2e2 chore: remove-duplicate-icons 2025-07-27 20:13:53 -04:00
Yang Yang b52d28c94c fix: Render template variables in folder system prompts
Fixes #16019
2025-07-27 23:20:43 +08:00
silentoplayz c03741df93 fix(frontend): Ensure 'Her' theme is always a light theme
This commit fixes a bug where the 'Her' theme would switch to dark mode after a logout/login cycle. The issue was caused by two problems:

1. The `dark` class was being incorrectly added to the `documentElement` when the 'Her' theme was selected in `src/app.html`.
2. The `applyTheme` function in `src/lib/components/chat/Settings/General.svelte` was not explicitly setting the 'Her' theme as a light theme.

This commit resolves both of these issues, ensuring that the 'Her' theme is always a light theme.
2025-07-27 04:02:05 -04:00
silentoplayz 1d85729b4a fix: prevent error when autoscrolling in FloatingButtons 2025-07-27 03:07:28 -04:00
silentoplayz 2d130f3bec chore: adjust discover links 2025-07-26 17:32:55 -04:00
silentoplayz 67baecdc8e fix 2025-07-25 16:05:42 -04:00
silentoplayz 92fe7544c8 Sticky top bars for Leaderboard and UserList pages 2025-07-25 15:57:03 -04:00
Tim Jaeryang Baek 48d233abc6 Merge pull request #16011 from open-webui/main
refac: styling
2025-07-25 01:09:54 +04:00
Timothy Jaeryang Baek b8912aa671 refac: styling 2025-07-25 01:09:04 +04:00
Classic298 723840dad5 add warning if OPENID_PROVIDER_URL Is not set 2025-07-24 22:15:58 +02:00
Timothy Jaeryang Baek df7b5ec907 enh/refac: function sync endpoint 2025-07-24 23:44:43 +04:00
Tim Jaeryang Baek 3220088411 Merge pull request #16007 from open-webui/main
fix: otel yaml
2025-07-24 22:48:26 +04:00
Tim Jaeryang Baek dcade8cdf8 Merge pull request #15785 from bekzod/patch-1
BREAKING CHANGE: Update docling endpoint
2025-07-24 21:09:13 +04:00
Timothy Jaeryang Baek 9f5c905592 fix: max count should not apply to knowledge 2025-07-24 19:43:17 +04:00
Tim Jaeryang Baek da01021836 Merge pull request #15885 from Classic298/admin_export_user_csv
feat: Add button (for admins) to export all users in DB as csv file
2025-07-24 19:29:38 +04:00
Timothy Jaeryang Baek 626ad9c83d fix: otel yaml 2025-07-24 19:25:13 +04:00
Tim Jaeryang Baek 4179e7e4c7 Merge pull request #15942 from amoshydra/pin-pyarrow-20-rpi-compatibility
chore: pin pyarrow version to 20 for RPI compatibility
2025-07-24 19:07:26 +04:00
Timothy Jaeryang Baek 6c476ec939 refac 2025-07-24 18:54:51 +04:00
Timothy Jaeryang Baek 7a97c25fa7 refac 2025-07-24 18:54:15 +04:00
Timothy Jaeryang Baek 274a65903b fix: otel.yaml 2025-07-24 18:53:04 +04:00
Tim Jaeryang Baek af45dfd002 Merge pull request #15980 from sihyeonn/feat/sh-share-connectionpool
feat: Reuse Redis Connection Pools to Eliminate Connection & IP Exhaustion
2025-07-24 18:38:39 +04:00
Sihyeon Jang f59da361f1 feat: Re-use Redis connection pools via local cache to prevent transient exhaustion
Every call to get_redis_connection() spawned a new pool, so workers slowly accumulated thousands of open sockets. Even though connections were eventually released, skewed release timing still pushed us past Redis’ max-clients and the cluster egress IP cap.

A module-level _CONNECTION_CACHE now memoises pools by (redis_url, sentinel_hosts, async_mode, decode_responses).

Result: flat connection count, no more IP or FD exhaustion. Public API unchanged.

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-07-24 18:44:42 +09:00
Tim Jaeryang Baek 78754fbe96 Merge pull request #15960 from itk-dev/feature/more-danish-translations
i18n: Danish translations (missing)
2025-07-23 20:15:40 +04:00
Jesper Kristensen 347e4dd73d Added missing danish translations 2025-07-23 11:35:52 +02:00
Tim Jaeryang Baek 4a3935ef9a Merge pull request #15935 from rgaricano/dev_FIX-transcription_language
FIX: STT default whisper trascription language
2025-07-23 12:20:08 +04:00
Tim Jaeryang Baek 4d93ad4474 Merge pull request #15941 from taylorwilsdon/tasks_redis_key_prefix
fix: Implement outstanding REDIS_KEY_PREFIX declarations
2025-07-23 12:18:03 +04:00
Timothy Jaeryang Baek ffa8ad0a22 refac: azure url detection 2025-07-23 12:15:02 +04:00
Tim Jaeryang Baek f44812b1a8 Merge pull request #15946 from rgaricano/dev_es-ES
i18n:UPD: Locale es-ES translation, updated to v.0.6.18
2025-07-23 12:02:34 +04:00
Tim Jaeryang Baek bd18bf5c83 Merge pull request #15951 from 0xThresh/s3vector-support
feat: Add S3 Vector Buckets Support for Knowledge
2025-07-23 12:02:20 +04:00
_00_ fc9d19e814 Update translation.json -Removed added strings
Removed added strings because are innecessary for autogenerating flow.
2025-07-23 09:22:48 +02:00
_00_ 86736a7817 Update translation.json REORDER STRING
REORDER STRING
2025-07-23 09:04:23 +02:00
_00_ 7a0b55758d Update translation.json - reorder strings
reorder strings
2025-07-23 09:01:26 +02:00
_00_ ab8e54dd29 Update audio.py Fix Format error
Fix Format error
2025-07-23 08:49:00 +02:00
_00_ 51758c429b Update audio.py - fix FORMAT error
Fix  FORMAT error
2025-07-23 08:45:10 +02:00
_00_ e71177f776 Merge branch 'dev' into dev_es-ES 2025-07-23 08:34:32 +02:00
0xThresh.eth 860f3b3cab chore: run formatting 2025-07-22 22:46:00 -06:00
0xThresh.eth 8dcf668448 chore: final cleanup 2025-07-22 22:37:57 -06:00
Richard Meyer 9590897f3a Merge branch 'open-webui:main' into marker-api-update 2025-07-23 00:35:48 -04:00
Hisma 216c991c01 move api key below url 2025-07-23 00:32:18 -04:00
0xThresh.eth 6c283cdd93 fix: boto3 version in uv.lock 2025-07-22 21:40:52 -06:00
0xThresh.eth d463a29ba1 feat: S3 vector support tested 2025-07-22 21:36:35 -06:00
Hisma 21337a2fd8 ci fix 2025-07-22 22:07:14 -04:00
Hisma a99e20cc3d add format_lines 2025-07-22 21:06:29 -04:00
Hisma f31cc07a9d feat: update marker api 2025-07-22 20:49:28 -04:00
_00_ 41ce1b5b28 i18n:UPD: Locale es-ES translation, updated to v.0.6.18
UPDATE i18n locale es-ES translation, updated to v.0.6.18.

Added translation of new strings
2025-07-23 01:38:34 +02:00
Timothy Jaeryang Baek 8da08ad73a refac: chat item edit title behaviour 2025-07-22 23:44:10 +04:00
Timothy Jaeryang Baek c03b574ef6 refac/fix 2025-07-22 23:39:33 +04:00
amoshydra b778bcd8e6 chore: pin pyarrow version to 20 for rpi compatibility
fix: #15897
2025-07-23 02:22:45 +08:00
Taylor Wilsdon 65654a3b4c fix last key prefix 2025-07-22 13:42:38 -04:00
Taylor Wilsdon 3e686e9915 implement redis_key_prefix env support in all areas invoking redis to allow for clustermode compatibility with new docs function 2025-07-22 13:40:29 -04:00
_00_ 0613563644 FIX: STT default whisper trascription language
FIX: STT default whisper trascription language

Fix the transcripcion language used by default whisper, setting as WHISPER_LANGUAGE if it is setted in env var, even if a language is detected in the file's metadata.
It is understood that if a language is set as an environment variable for transcriptions, this should be the preferred one and the one that should be used for that purpose.

It would be advisable to add this variable as configurable in UI
2025-07-22 16:47:06 +02:00
Timothy Jaeryang Baek d21a2581a6 refac/fix: title generation button issue 2025-07-22 17:31:01 +04:00
Timothy Jaeryang Baek 8bc7d85eac refac 2025-07-22 17:17:26 +04:00
Tim Jaeryang Baek dfdf94483b Merge pull request #15921 from expruc/feat/allow_admins_handle_feedbacks
fix: allow admins to get and post on feedbacks (avoid 404 errors)
2025-07-22 14:43:59 +04:00
Timothy Jaeryang Baek bf3c807047 refac 2025-07-22 11:38:47 +04:00
Timothy Jaeryang Baek ac0932f356 refac: styling 2025-07-22 11:22:01 +04:00
Timothy Jaeryang Baek 496ea40d1d refac: model item tag styling 2025-07-22 11:19:40 +04:00
0xThresh.eth f6ee1965cb merge main 2025-07-21 18:06:17 -06:00
expruc 1ff2ad0c08 ci fix 2025-07-21 22:17:08 +03:00
expruc 14c6f8bf80 added a check for admins to avoid 404 errors on feedbacks get and post 2025-07-21 21:18:37 +03:00
Timothy Jaeryang Baek d8b80caff3 refac/fix: remove insecure arg for otel http exporter 2025-07-21 16:35:23 +04:00
Timothy Jaeryang Baek a26607c720 refac: styling 2025-07-21 13:06:34 +04:00
Tim Jaeryang Baek 1e2ae1da1d Merge pull request #15888 from macnow/patch-2
i18n: update for polish translation
2025-07-21 10:57:02 +04:00
Tim Jaeryang Baek e95a93d80b Merge pull request #15894 from qingchunnh/Update_zh-CN-25721
i18n: Update & Improve & Fix zh-CN
2025-07-21 10:53:53 +04:00
qingchun 8f655ec0a6 i18n: Fix zh-CN 2025-07-21 06:59:12 +08:00
qingchun fa3c3c2443 i18n: Update & Improve & Fix zh-CN 2025-07-21 06:52:46 +08:00
0xThresh.eth 5c59c50e2d more prgoress on s3 vector 2025-07-20 16:48:23 -06:00
Maciej Nowakowski 58cbf5aba2 Update for Polish translation 2025-07-20 14:30:53 +02:00
Classic298 3acd286791 Merge branch 'dev' into admin_export_user_csv 2025-07-20 13:17:59 +02:00
Timothy Jaeryang Baek 24805ca79e refac/fix: channel messages 2025-07-20 15:17:17 +04:00
Timothy Jaeryang Baek ed20f2ea5f fix: dev.sh 2025-07-20 15:10:54 +04:00
Timothy Jaeryang Baek 40ebf8cd62 refac: memory handling 2025-07-20 15:00:24 +04:00
Tim Jaeryang Baek 521d223f05 Merge pull request #15883 from aleixdorca/dev
i18n: Update catalan translation.json
2025-07-20 14:14:57 +04:00
Classic298 a2aa68c84a Update Database.svelte 2025-07-20 11:16:08 +02:00
Classic298 74140ea251 Update Database.svelte 2025-07-20 11:12:38 +02:00
Classic298 84f7a55753 remove slash 2025-07-20 11:04:40 +02:00
Classic298 0d1b06d87c Update Database.svelte 2025-07-20 10:52:47 +02:00
Aleix Dorca 28d54f87f1 Update catalan translation.json 2025-07-20 06:51:10 +02:00
Tim Jaeryang Baek 5fbfe2bdca Merge pull request #15879 from open-webui/dev
0.6.18
2025-07-19 23:26:01 +04:00
Timothy Jaeryang Baek 6e0328798d chore: format 2025-07-19 23:23:54 +04:00
Tim Jaeryang Baek 0119036a11 Merge pull request #15871 from Classic298/cors
Enh: dev.sh default port export
2025-07-19 23:20:52 +04:00
Tim Jaeryang Baek 7a023e0841 Merge pull request #15875 from leebeomhun/i18n-ko
i18n: update and fix ko-KR translation
2025-07-19 23:20:29 +04:00
Timothy Jaeryang Baek b397008ee0 doc: changelog 2025-07-19 23:20:14 +04:00
Timothy Jaeryang Baek b329c63c80 refac 2025-07-19 23:17:14 +04:00
Timothy Jaeryang Baek 95b11207a1 fix: group user load 2025-07-19 23:16:44 +04:00
BHLee 74d0e6b2c7 i18n korean Fix typo 2025-07-20 04:10:26 +09:00
BHLee 7ffc12567f i18n update and fix:ko-KR translation.json 2025-07-20 03:46:37 +09:00
Classic298 d8ed4fa0c2 Merge branch 'open-webui:main' into cors 2025-07-19 19:53:13 +02:00
Classic298 549294ee30 Update dev.sh 2025-07-19 19:43:08 +02:00
bekzod 4bc054a347 Update docling endpoint 2025-07-16 20:40:13 +05:00
0xThresh.eth d9f2b6b14e feat: add starter config for s3 vector 2025-07-15 21:20:54 -06:00
_00_ 5889e8238a UPD: BM25 Hybrid Slider - Legend inline
BM25 Hybrid Slider - Legend (lexical - semantic) inline
2025-07-14 22:17:37 +02:00
_00_ 3345514efc UPD: en-EN locales add lexical, semantic & fix previous incomplete
en-EN locales add lexical, semantic & fix previous incomplete
2025-07-14 21:23:35 +02:00
_00_ fd735590da UPD: Add lexical-semantic reference above slider
Add lexical-semantic reference above slider
2025-07-14 21:20:11 +02:00
_00_ cd554ebce5 UPD: Add Weight BM25 Hybrid Search string
UPD: Add Weight BM25 Hybrid Search string
to fit with tooltip of new slider in PR: https://github.com/open-webui/open-webui/pull/15705
"The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5"
2025-07-14 20:19:06 +02:00
_00_ 4a6d333a03 FEAT: Frontend HybridSearch-BM25 Slider
FEAT: Frontend HybridSearch-BM25 Slider

Front-end modification for the BM25-Weight parameter input.

This proposal modifies the numerical typed input to a slider similar to the advanced model parameters.
2025-07-14 17:48:49 +02:00
Dieu 41faec758b Merge branch 'dev' into feat/scim-2.0-support 2025-07-14 00:48:50 +02:00
Dieu 6469822af1 Update configs.py 2025-07-14 00:44:25 +02:00
Dieu 519b7af6d8 remove useless 2025-07-14 00:36:14 +02:00
Dieu 1d9c1f741d Update scim.py 2025-07-13 23:54:33 +02:00
Dieu c9fda793e2 udpate 2025-07-13 23:42:33 +02:00
Dieu 39bcee3f7b remove ui config 2025-07-13 23:24:32 +02:00
DieuandClaude f4d54c518e feat: Add SCIM 2.0 support for enterprise user provisioning
Implements SCIM 2.0 protocol for automated user and group provisioning from identity providers like Okta, Azure AD, and Google Workspace.

Backend changes:
- Add SCIM configuration with PersistentConfig for database persistence
- Implement SCIM 2.0 endpoints (Users, Groups, ServiceProviderConfig)
- Add bearer token authentication for SCIM requests
- Include comprehensive test coverage for SCIM functionality

Frontend changes:
- Add SCIM admin settings page with token generation
- Implement SCIM configuration management UI
- Add save functionality and proper error handling
- Include SCIM statistics display

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 16:34:41 +02:00
Ioannis Papapanagiotou 54d606eabf refactor(logger): Conditionally include extra_json in logs 2025-07-11 01:29:27 -07:00
Wonyong Lee a8a8fd537f yarn.lock removed 2025-07-10 12:15:13 +00:00
Wonyong Lee 46e0992a83 json_serialize returing varchar2(2096) 2025-07-10 12:12:43 +00:00
Wonyong Lee 39e4551a45 add oracledb package to requirements.txt 2025-07-10 04:28:52 +00:00
Oracle Public Cloud User e0afd7f496 fianl : vector-search-feature 2025-07-07 17:25:16 +00:00
Oracle Public Cloud User 12ebdbae81 refactor oracle23ai.py 2025-07-07 16:21:34 +00:00
Oracle Public Cloud User 25e241ae41 added new feature : oracle23ai vector search 2025-07-07 12:13:05 +00:00
Oracle Public Cloud User b56dbb26be alpha2 2025-07-07 08:52:58 +00:00
Oracle Public Cloud User 3e2fd074bb oracle 23ai vector search 2025-07-07 05:58:02 +00:00
Rodrigo AgundezandKG bb09245792 Inject code to block imports from blacklisted modules
Co-authored-by: KG <kahghi@users.noreply.github.com>
2025-06-05 23:21:37 +08:00
Rodrigo AgundezandKG 6dd969129d Add option to blacklist modules in code interpreter
Co-authored-by: KG <kahghi@users.noreply.github.com>
2025-06-05 23:21:04 +08:00
Olivier Lacroix 4f982e244f Ensure tool callable can be used by genai directly 2025-06-05 10:58:22 +10:00
373 changed files with 33404 additions and 11194 deletions
+8 -5
View File
@@ -11,7 +11,7 @@ body:
## Important Notes
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
@@ -25,7 +25,9 @@ body:
label: Check Existing Issues
description: Confirm that you’ve checked for existing reports before submitting a new one.
options:
- label: I have searched the existing issues and discussions.
- label: I have searched for any existing and/or related issues.
required: true
- label: I have searched for any existing and/or related discussions.
required: true
- label: I am using the latest version of Open WebUI.
required: true
@@ -47,7 +49,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.3.11)
description: Specify the version (e.g., v0.6.26)
validations:
required: true
@@ -63,7 +65,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
validations:
required: true
@@ -126,6 +128,7 @@ body:
description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
placeholder: |
Example (include every detail):
@@ -163,5 +166,5 @@ body:
attributes:
value: |
## Note
If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
Thank you for contributing to Open WebUI!
-6
View File
@@ -12,12 +12,6 @@ updates:
interval: monthly
target-branch: 'dev'
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
+1 -1
View File
@@ -73,4 +73,4 @@
### Contributor License Agreement
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
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.
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
@@ -36,7 +36,7 @@ jobs:
echo "::set-output name=content::$CHANGELOG_ESCAPED"
- name: Create GitHub release
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -61,7 +61,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build workflow
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
+166 -8
View File
@@ -43,7 +43,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -142,7 +142,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -244,7 +244,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -347,7 +347,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -419,6 +419,108 @@ jobs:
if-no-files-found: error
retention-days: 1
build-slim-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-slim-${{ matrix.platform }}-
latest=false
- name: Build Docker image (slim)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-slim-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
@@ -433,7 +535,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
@@ -487,7 +589,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
@@ -543,7 +645,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
@@ -599,7 +701,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
@@ -640,3 +742,59 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-slim-images:
runs-on: ubuntu-latest
needs: [build-slim-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-slim-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
- 3.12.x
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '${{ matrix.python-version }}'
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '22'
@@ -51,10 +51,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '22'
+3 -3
View File
@@ -16,15 +16,15 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Git
run: sudo apt-get update && sudo apt-get install -y git
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 22
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: 3.11
- name: Build
+4 -1
View File
@@ -1,3 +1,5 @@
x.py
yarn.lock
.DS_Store
node_modules
/build
@@ -12,7 +14,8 @@ vite.config.ts.timestamp-*
__pycache__/
*.py[cod]
*$py.class
.nvmrc
CLAUDE.md
# C extensions
*.so
+363
View File
@@ -5,6 +5,369 @@ 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.6.28] - 2025-09-10
### Added
- 🔍 The "@" command for model selection now supports real-time search and filtering, improving usability and aligning its behavior with other input commands. [#17307](https://github.com/open-webui/open-webui/issues/17307), [Commit](https://github.com/open-webui/open-webui/commit/f2a09c71499489ee71599af4a179e7518aaf658b)
- 🛠️ External tool server data handling is now more robust, automatically attempting to parse specifications as JSON before falling back to YAML, regardless of the URL extension. [Commit](https://github.com/open-webui/open-webui/commit/774c0056bde88ed4831422efa81506488e3d6641)
- 🎯 The "Title" field is now automatically focused when creating a new chat folder, streamlining the folder creation process. [#17315](https://github.com/open-webui/open-webui/issues/17315), [Commit](https://github.com/open-webui/open-webui/commit/c51a651a2d5e2a27546416666812e9b92205562d)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Brazilian Portuguese and Simplified Chinese translations were expanded and refined.
### Fixed
- 🔊 A regression affecting Text-to-Speech for local providers using the OpenAI engine was fixed by reverting a URL joining change. [#17316](https://github.com/open-webui/open-webui/issues/17316), [Commit](https://github.com/open-webui/open-webui/commit/8339f59cdfc63f2d58c8e26933d1bf1438479d75)
- 🪧 A regression was fixed where the input modal for prompts with placeholders would not open, causing the raw prompt text to be pasted into the chat input field instead. [#17325](https://github.com/open-webui/open-webui/issues/17325), [Commit](https://github.com/open-webui/open-webui/commit/d5cb65527eaa4831459a4c7dbf187daa9c0525ae)
- 🔑 An issue was resolved where modified connection keys in the OpenAIConnection component did not take effect. [#17324](https://github.com/open-webui/open-webui/pull/17324)
## [0.6.27] - 2025-09-09
### Added
- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255)
- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5)
- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a)
- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158)
- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679)
- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166)
- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222)
- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229)
- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7)
- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9)
- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672)
- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880)
- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df)
- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae)
- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046)
- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195)
- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095)
- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b)
- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175)
- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9)
- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012)
- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061)
- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064)
- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000)
- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b)
- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085)
- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186)
- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191)
- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213)
- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225)
- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb)
- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6)
- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de)
- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6)
- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4)
- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958)
- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e)
### Changed
- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f)
- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285)
## [0.6.26] - 2025-08-28
### Added
- 🛂 **Granular Chat Interaction Permissions**: Added fine-grained permission controls for individual chat actions including "Continue Response", "Regenerate Response", "Rate Response", and "Delete Messages". Administrators can now configure these permissions per user group or set system defaults via environment variables, providing enhanced security and governance by preventing potential system prompt leakage through response continuation and enabling precise control over user interactions with AI responses.
- 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility.
- 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA.
- 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows.
- 🔐 **Azure Document Intelligence DefaultAzureCredential Support**: Added support for authenticating with Azure Document Intelligence using DefaultAzureCredential in addition to API key authentication, enabling seamless integration with Azure Entra ID and managed identity authentication for enterprise Azure environments.
- 🔐 **Authentication Bootstrapping Enhancements**: Added "ENABLE_INITIAL_ADMIN_SIGNUP" environment variable and "?form=true" URL parameter to enable initial admin user creation and forced login form display in SSO-only deployments. This resolves bootstrap issues where administrators couldn't create the first user when login forms were disabled, allowing proper initialization of SSO-configured deployments without requiring temporary configuration changes.
- ⚡ **Query Generation Caching**: Added "ENABLE_QUERIES_CACHE" environment variable to enable request-scoped caching of generated search queries. When both web search and file retrieval are active, queries generated for web search are automatically reused for file retrieval, eliminating duplicate LLM API calls and reducing token usage and costs while maintaining search quality.
- 🔧 **Configurable Tool Call Retry Limit**: Added "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" environment variable to control the maximum number of sequential tool calls allowed before safety stopping a chat session. This replaces the previous hardcoded limit of 10, enabling administrators to configure higher limits for complex workflows requiring extensive tool interactions.
- 📦 **Slim Docker Image Variant**: Added new slim Docker image option via "USE_SLIM" build argument that excludes embedded AI models and Ollama installation, reducing image size by approximately 1GB. This variant enables faster image pulls and deployments for environments where AI models are managed externally, particularly beneficial for auto-scaling clusters and distributed deployments.
- 🗂️ **Shift-to-Delete Functionality for Workspace Prompts**: Added keyboard shortcut support for quick prompt deletion on the Workspace Prompts page. Hold Shift and hover over any prompt to reveal a trash icon for instant deletion, bringing consistent interaction patterns across all workspace sections (Models, Tools, Functions, and now Prompts) and streamlining prompt management workflows.
- ♿ **Accessibility Enhancements**: Enhanced user interface accessibility with improved keyboard navigation, ARIA labels, and screen reader compatibility across key platform components.
- 📄 **Optimized PDF Export for Smaller File Size**: PDF exports are now significantly optimized, producing much smaller files for faster downloads and easier sharing or archiving of your chats and documents.
- 📦 **Slimmed Default Install with Optional Full Dependencies**: Installing Open WebUI via pip now defaults to a slimmer package; PostgreSQL support is no longer included by default—simply use 'pip install open-webui[all]' to include all optional dependencies for full feature compatibility.
- 🔄 **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users.
- 🌐 **Localization & Internationalization Improvements**: Enhanced and expanded translations for Finnish, Spanish, Japanese, Polish, Portuguese (Brazil), and Chinese, including missing translations and typo corrections, providing a more natural and professional user experience for speakers of these languages across the entire interface.
### Fixed
- ⚠️ **Chat Error Feedback Restored**: Fixed an issue where various backend errors (tool server failures, API connection issues, malformed responses) would cause chats to load indefinitely without providing user feedback. The system now properly displays error messages when failures occur during chat generation, allowing users to understand issues and retry as needed instead of waiting indefinitely.
- 🖼️ **Image Generation Steps Setting Visibility Fixed**: Fixed a UI issue where the "Set Steps" configuration option was incorrectly displayed for OpenAI and Gemini image generation engines that don't support this parameter. The setting is now only visible for compatible engines like ComfyUI and Automatic1111, eliminating user confusion about non-functional configuration options.
- 📄 **Datalab Marker API Document Loader Fixed**: Fixed broken Datalab Marker API document loader functionality by correcting URL path handling for both hosted Datalab services and self-hosted Marker servers. Removed hardcoded "/marker" paths from the loader code and restored proper default URL structure, ensuring PDF and document processing works correctly with both deployment types.
- 📋 **Citation Error Handling Improved**: Fixed an issue where malformed citation or source objects from external tools, pipes, or filters would cause JavaScript errors and make the chat interface completely unresponsive. The system now gracefully handles missing or undefined citation properties, allowing conversations to load properly even when tools generate defective citation events.
- 👥 **Group User Add API Endpoint Fixed**: Fixed an issue where the "/api/v1/groups/id/{group_id}/users/add" API endpoint would accept requests without errors but fail to actually add users to groups. The system now properly initializes and deduplicates user ID lists, ensuring users are correctly added to and removed from groups via API calls.
- 🛠️ **External Tool Server Error Handling Improved**: Fixed an issue where unreachable or misconfigured external tool servers would cause JavaScript errors and prevent the interface from loading properly. The system now gracefully handles connection failures, displays appropriate error messages, and filters out inaccessible servers while maintaining full functionality for working connections.
- 📋 **Code Block Copy Button Content Fixed**: Fixed an issue where the copy button in code blocks would copy the original AI-generated code instead of any user-edited content, ensuring the copy function always captures the currently displayed code as modified by users.
- 📄 **PDF Export Content Mismatch Fixed**: Resolved an issue where exporting a PDF of one chat while viewing another chat would incorrectly generate the PDF using the currently viewed chat's content instead of the intended chat's content. Additionally optimized the PDF generation algorithm with improved canvas slicing, better memory management, and enhanced image quality, while removing the problematic PDF export option from individual chat menus to prevent further confusion.
- 🖱️ **Windows Sidebar Cursor Icon Corrected**: Fixed confusing cursor icons on Windows systems where sidebar toggle buttons displayed resize cursors (ew-resize) instead of appropriate pointer cursors. The sidebar buttons now show standard pointer cursors on Windows, eliminating user confusion about whether the buttons expand/collapse the sidebar or resize it.
- 📝 **Safari IME Composition Bug Fixed**: Resolved an issue where pressing Enter while composing Chinese text using Input Method Editors (IMEs) on Safari would prematurely send messages instead of completing text composition. The system now properly detects composition states and ignores keydown events that occur immediately after composition ends, ensuring smooth multilingual text input across all browsers.
- 🔍 **Hybrid Search Parameter Handling Fixed**: Fixed an issue where the "hybrid" parameter in collection query requests was not being properly evaluated, causing the system to ignore user-specified hybrid search preferences and only check global configuration. Additionally resolved a division by zero error that occurred in hybrid search when BM25Retriever was called with empty document lists, ensuring robust search functionality across all collection states.
- 💬 **RTL Text Orientation in Messages Fixed**: Fixed text alignment issues in user messages and AI responses for Right-to-Left languages, ensuring proper text direction based on user language settings. Code blocks now consistently use Left-to-Right orientation regardless of the user's language preference, maintaining code readability across all supported languages.
- 📁 **File Content Preview in Modal Restored**: Fixed an issue where clicking on uploaded files would display an empty preview modal, even when the files were successfully processed and available for AI context. File content now displays correctly in the preview modal, ensuring users can verify and review their uploaded documents as intended.
- 🌐 **Playwright Timeout Configuration Corrected**: Fixed an issue where Playwright timeout values were incorrectly converted from milliseconds to seconds with an additional 1000x multiplier, causing excessively long web loading timeouts. The timeout parameter now correctly uses the configured millisecond values as intended, ensuring responsive web search and document loading operations.
### Changed
- 🔄 **Follow-Up Question Language Constraint Removed**: Follow-up question suggestions no longer strictly adhere to the chat's primary language setting, allowing for more flexible and diverse suggestion generation that may include questions in different languages based on conversation context and relevance rather than enforced language matching.
## [0.6.25] - 2025-08-22
### Fixed
- 🖼️ **Image Generation Reliability Restored**: Fixed a key issue causing image generation failures.
- 🏆 **Reranking Functionality Restored**: Resolved errors with rerank feature.
## [0.6.24] - 2025-08-21
### Added
- ♿ **High Contrast Mode in Chat Messages**: Implemented enhanced High Contrast Mode support for chat messages, making text and important details easier to read and improving accessibility for users with visual preferences or requirements.
- 🌎 **Localization & Internationalization Improvements**: Enhanced and expanded translations for a more natural and professional user experience for speakers of these languages across the entire interface.
### Fixed
- 🖼️ **ComfyUI Image Generation Restored**: Fixed a critical bug where ComfyUI-based image generation was not functioning, ensuring users can once again effortlessly create and interact with AI-generated visuals in their workflows.
- 🛠️ **Tool Server Loading and Visibility Restored**: Resolved an issue where connected tool servers were not loading or visible, restoring seamless integration and uninterrupted access to all external and custom tools directly within the platform.
- 🛡️ **Redis User Session Reliability**: Fixed a problem affecting the saving of user sessions in Redis, ensuring reliable login sessions, stable authentication, and secure multi-user environments.
## [0.6.23] - 2025-08-21
### Added
- ⚡ **Asynchronous Chat Payload Processing**: Refactored the chat completion pipeline to return a response immediately for streaming requests involving web search or tool calls. This enables users to stop ongoing generations promptly and preventing network timeouts during lengthy preprocessing phases, thus significantly improving user experience and responsiveness.
- 📁 **Asynchronous File Upload with Polling**: Implemented an asynchronous file upload process with frontend polling to resolve gateway timeouts and improve reliability when uploading large files. This ensures that even lengthy file processing, such as embedding or transcription, does not block the user interface or lead to connection timeouts, providing a smoother experience for all file operations.
- 📈 **Database Performance Indexes and Migration Script**: Introduced new database indexes on the "chat", "tag", and "function" tables to significantly enhance query performance for SQLite and PostgreSQL installations. For existing deployments, a new Alembic migration script is included to seamlessly apply these indexes, ensuring faster filtering and sorting operations across the platform.
- ✨ **Enhanced Database Performance Options**: Introduced new configurable options to significantly improve database performance, especially for SQLite. This includes "DATABASE_ENABLE_SQLITE_WAL" to enable SQLite WAL (Write-Ahead Logging) mode for concurrent operations, and "DATABASE_DEDUPLICATE_INTERVAL" which, in conjunction with a new deduplication mechanism, reduces redundant updates to "user.last_active_at", minimizing write conflicts across all database types.
- 💾 **Save Temporary Chats Button**: Introduced a new 'Save Chat' button for conversations initiated in temporary mode. This allows users to permanently save valuable temporary conversations to their chat history, providing greater flexibility and ensuring important discussions are not lost.
- 📂 **Chat Movement Options in Menu**: Added the ability to move chats directly to folders from the chat menu. This enhances chat organization and allows users to manage their conversations more efficiently by relocating them between folders with ease.
- 💬 **Language-Aware Follow-Up Suggestions**: Enhanced the AI's follow-up question generation to dynamically adapt to the primary language of the current chat. Follow-up prompts will now be suggested in the same language the user and AI are conversing in, ensuring more natural and contextually relevant interactions.
- 👤 **Expanded User Profile Details**: Introduced new user profile fields including username, bio, gender, and date of birth, allowing for more comprehensive user customization and information management. This enhancement includes corresponding updates to the database schema, API, and user interface for seamless integration.
- 👥 **Direct Navigation to User Groups from User Edit**: Enhanced the user edit modal to include a direct link to the associated user group. This allows administrators to quickly navigate from a user's profile to their group settings, streamlining user and group management workflows.
- 🔧 **Enhanced External Tool Server Compatibility**: Improved handling of responses from external tool servers, allowing both the backend and frontend to process plain text content in addition to JSON, ensuring greater flexibility and integration with diverse tool outputs.
- 🗣️ **Enhanced Audio Transcription Language Fallback and Deepgram Support**: Implemented a robust language fallback mechanism for both OpenAI and Deepgram Speech-to-Text (STT) API calls. If a specified language parameter is not supported by the model or provider, the system will now intelligently retry the transcription without the language parameter or with a default, ensuring greater reliability and preventing failed API calls. This also specifically adds and refines support for the audio language parameter in Deepgram API integrations.
- ⚡ **Optimized Hybrid Search Performance for BM25 Weight Configuration**: Enhanced hybrid search to significantly improve performance when the BM25 weight is set to 0 or less. This optimization intelligently disables unnecessary collection retrieval and BM25 ranking calculations, leading to faster search results without impacting accuracy for configurations that do not utilize lexical search contributions.
- 🔒 **Configurable Code Interpreter Module Blacklist**: Introduced the "CODE_INTERPRETER_BLACKLISTED_MODULES" environment variable, allowing administrators to specify Python modules that are forbidden from being imported or executed within the code interpreter. This significantly enhances the security posture by mitigating risks associated with arbitrary code execution, such as unauthorized data access, system manipulation, or outbound connections.
- 🔐 **Enhanced OAuth Role Claim Handling**: Improved compatibility with diverse OAuth providers by allowing role claims to be supplied as single strings or integers, in addition to arrays. The system now automatically normalizes these single-value claims into arrays for consistent processing, streamlining integration with identity providers that format role data differently.
- ⚙️ **Configurable Tool Call Timeout**: Introduced the "AIOHTTP_CLIENT_TIMEOUT" environment variable, allowing administrators to specify custom timeout durations for external tool calls, which is crucial for integrations with tools that have varying or extended response times.
- 🛠️ **Improved Tool Callable Generation for Google genai SDK**: Enhanced the creation of tool callables to directly support native function calling within the Google 'genai' SDK. This refactoring ensures proper signature inference and removes extraneous parameters, enabling seamless integration for advanced AI workflows using Google's generative AI models.
- ✨ **Dynamic Loading of 'kokoro-js'**: Implemented dynamic loading for the 'kokoro-js' library, preventing failures and improving compatibility on older iOS browsers that may not support direct imports or certain modern JavaScript APIs like 'DecompressionStream'.
- 🖥️ **Improved Command List Visibility on Small Screens**: Resolved an issue where the top items in command lists (e.g., Knowledge Base, Models, Prompts) were hidden or overlapped by the header on smaller screen sizes or specific browser zoom levels. The command option lists now dynamically adjust their height, ensuring all items are fully visible and accessible with proper scrolling.
- 📦 **Improved Docker Image Compatibility for Arbitrary UIDs**: Fixed issues preventing the Open WebUI container from running in environments with arbitrary User IDs (UIDs), such as OpenShift's restricted Security Context Constraints (SCC). The Dockerfile has been updated to correctly set file system permissions for "/app" and "/root" directories, ensuring they are writable by processes running with a supplemental GID 0, thus resolving permission errors for Python libraries and application caches.
- ♿ **Accessibility Enhancements**: Significantly improved the semantic structure of chat messages by using "section", "h2", "ul", and "li" HTML tags, and enhanced screen reader compatibility by explicitly hiding decorative images with "aria-hidden" attributes. This refactoring provides clearer structural context and improves overall accessibility and web standards compliance for the conversation flow.
- 🌐 **Localization & Internationalization Improvements**: Significantly expanded internationalization support throughout the user interface, translating numerous user-facing strings in toast messages, placeholders, and other UI elements. This, alongside continuous refinement and expansion of translations for languages including Brazilian Portuguese, Kabyle (Taqbaylit), Czech, Finnish, Chinese (Simplified), Chinese (Traditional), and German, and general fixes for several other translation files, further enhances linguistic coverage and user experience.
### Fixed
- 🛡️ **Resolved Critical OIDC SSO Login Failure**: Fixed a critical issue where OIDC Single Sign-On (SSO) logins failed due to an error in setting the authentication token as a cookie during the redirect process. This ensures reliable and seamless authentication for users utilizing OIDC providers, restoring full login functionality that was impacted by previous security hardening.
- ⚡ **Prevented UI Blocking by Unreachable Webhooks**: Resolved a critical performance and user experience issue where synchronous webhook calls to unreachable or slow endpoints would block the entire user interface for all users. Webhook requests are now processed asynchronously using "aiohttp", ensuring that the UI remains responsive and functional even if webhook delivery encounters delays or failures.
- 🔒 **Password Change Option Hidden for Externally Authenticated Users**: Resolved an issue where the password change dialog was visible to users authenticated via external methods (e.g., LDAP, OIDC, Trusted Header). The option to change a password in user settings is now correctly hidden for these users, as their passwords are managed externally, streamlining the user interface and preventing confusion.
- 💬 **Resolved Temporary Chat and Permission Enforcement Issues**: Fixed a bug where temporary chats (identified by "chat_id = local") incorrectly triggered database checks, leading to 404 errors. This also resolves the issue where the 'USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED' setting was not functioning as intended, ensuring temporary chat mode now works correctly for user roles.
- 🔐 **Admin Model Visibility for Administrators**: Private models remained visible and usable for administrators in the chat model selector, even when the intended privacy setting ("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" - now renamed to "BYPASS_ADMIN_ACCESS_CONTROL") was disabled. This ensures consistent enforcement of model access controls and adherence to the principle of least privilege.
- 🔍 **Clarified Web Search Engine Label for DDGS**: Addressed user confusion and inaccurate labeling by renaming "duckduckgo" to "DDGS" (Dux Distributed Global Search) in the web search engine selector. This clarifies that the system utilizes DDGS, a metasearch library that aggregates results from various search providers, accurately reflecting its underlying functionality rather than implying exclusive use of DuckDuckGo's search engine.
- 🛠️ **Improved Settings UI Reactivity and Visibility**: Resolved an issue where settings tabs for 'Connections' and 'Tools' did not dynamically update their visibility based on global administrative feature flags (e.g., 'enable_direct_connections'). The UI now reactively shows or hides these sections, ensuring a consistent and clear experience when administrators control feature availability.
- 🎚️ **Restored Model and Banner Reordering Functionality**: Fixed a bug that prevented administrators from reordering models in the Admin Panel's 'Models' settings and banners in the 'Interface' settings via drag-and-drop. The sortable functionality has been restored, allowing for proper customization of display order.
- 📝 **Restored Custom Pending User Overlay Visibility**: Fixed an issue where the custom title and description configured for pending users were not visible. The application now correctly exposes these UI configuration settings to pending users, ensuring that the custom onboarding messages are displayed as intended.
- 📥 **Fixed Community Function Import Compatibility**: Resolved an issue that prevented the successful import of function files downloaded from openwebui.com due to schema differences. The system now correctly processes these files, allowing for seamless integration of community-contributed functions.
- 📦 **Fixed Stale Ollama Version in Docker Images**: Resolved an issue where the Ollama installation within Docker images could become stale due to caching during the build process. The Dockerfile now includes a mechanism to invalidate the build cache for the Ollama installation step, ensuring that the latest version of Ollama is always installed.
- 🗄️ **Improved Milvus Query Handling for Large Datasets**: Fixed a "MilvusException" that occurred when attempting to query more than 16384 entries from a Milvus collection. The query logic has been refactored to use "query_iterator()", enabling efficient fetching of larger result sets in batches and resolving the previous limitation on the number of entries that could be retrieved.
- 🐛 **Restored Message Toolbar Icons for Empty Messages with Files**: Fixed an issue where the edit, copy, and delete icons were not displayed on user messages that contained an attached file but no text content. This ensures full interaction capabilities for all message types, allowing users to manage their messages consistently.
- 💬 **Resolved Streaming Interruption for Kimi-Dev Models**: Fixed an issue where streaming responses from Kimi-Dev models would halt prematurely upon encountering specific 'thinking' tokens (◁think▷, ◁/think▷). The system now correctly processes these tokens, ensuring uninterrupted streaming and proper handling of hidden or collapsible thinking sections.
- 🔍 **Enhanced Knowledge Base Search Functionality**: Improved the search capability within the 'Knowledge' section of the Workspace. Previously, searching for knowledge bases required exact term matches or starting with the first letter. Now, the search algorithm has been refined to allow broader, less exact matches, making it easier and more intuitive to find relevant knowledge bases.
- 📝 **Resolved Chinese Input 'Enter' Key Issue (macOS & iOS Safari)**: Fixed a bug where pressing the 'Enter' key during text composition with Input Method Editors (IMEs) on macOS and iOS Safari browsers would prematurely send the message. The system now robustly handles the composition state by addressing a 'compositionend' event bug specific to Safari, ensuring a smooth and expected typing experience for users of various languages, including Chinese and Korean.
- 🔐 **Resolved OAUTH_GROUPS_CLAIM Configuration Issue**: Fixed a bug where the "OAUTH_GROUPS_CLAIM" environment variable was not correctly parsed due to a typo in the configuration file. This ensures that OAuth group management features, including automatic group creation, now correctly utilize the specified claim from the identity provider, allowing for seamless integration with external user directories like Keycloak.
- 🗄️ **Resolved Azure PostgreSQL pgvector Extension Permissions**: Fixed an issue preventing the creation of "pgvector" and "pgcrypto" extensions on Azure PostgreSQL Flexible Servers due to permission limitations (e.g., 'Only members of "azure_pg_admin" are allowed to use "CREATE EXTENSION"'). The extension creation process now includes a conditional check, ensuring seamless deployment and compatibility with Azure PostgreSQL environments even with restricted database user permissions.
- 🛠️ **Improved Backend Path Resolution and Alembic Stability**: Fixed issues causing Alembic database migrations to fail due to incorrect path resolution within the application. By implementing canonical path resolution for core directories and refining Alembic configuration, the robustness and correctness of internal pathing have been significantly enhanced, ensuring reliable database operations.
- 📊 **Resolved Arena Model Identification in Feedback History**: Fixed an issue where the model used for feedback in arena settings was incorrectly reported as 'arena-model' in the evaluation history. The system now correctly logs and displays the actual model ID that received the feedback, restoring clarity and enabling proper analysis of model performance in arena environments.
- 🎨 **Resolved Icon Overlap in 'Her' Theme**: Fixed a visual glitch in the 'Her' theme where icons would overlap on the loading screen and certain icons appeared incongruous. The display has been corrected to ensure proper visual presentation and theme consistency.
- 🛠️ **Resolved Model Sorting TypeError with Null Names**: Fixed a "TypeError" that occurred in the "/api/models" endpoint when sorting models with null or missing names. The model sorting logic has been improved to gracefully handle such edge cases by ensuring that model IDs and names are treated as empty strings if their values are null or undefined, preventing comparison errors and improving API stability.
- 💬 **Resolved Silently Dropped Streaming Response Chunks**: Fixed an issue where the final partial chunks of streaming chat responses could be silently dropped, leading to incomplete message delivery. The system now reliably flush any pending delta data upon stream termination, early breaks (e.g., code interpreter tags), or connection closure, ensuring complete and accurate response delivery.
- 📱 **Disabled Overscroll for iOS Frontend**: Fixed an issue where overscrolling was enabled on iOS devices, causing unexpected scrolling behavior over fixed or sticky elements within the PWA. Overscroll has now been disabled, providing a more native application-like experience for iOS users.
- 📝 **Resolved Code Block Input Issue with Shift+Enter**: Fixed a bug where typing three backticks followed by a language and then pressing Shift+Enter would cause the code block prefix to disappear, preventing proper code formatting. The system now correctly preserves the code block syntax, ensuring consistent behavior for multi-line code input.
- 🛠️ **Improved OpenAI Model List Handling for Null Names**: Fixed an edge case where some OpenAI-compatible API providers might return models with a null value for their 'name' field. This could lead to issues like broken model list sorting. The system now gracefully handles these instances by removing the null 'name' key, ensuring stable model retrieval and display.
- 🔍 **Resolved DDGS Concurrent Request Configuration**: Fixed an issue where the configured number of concurrent requests was not being honored for the DDGS (Dux Distributed Global Search) metasearch engine. The system now correctly applies the specified concurrency setting, improving efficiency for web searches.
- 🛠️ **Improved Tool List Synchronization in Multi-Replica Deployments**: Resolved an issue where tool updates were not consistently reflected across all instances in multi-replica environments, leading to stale tool lists for users on other replicas. The tool list in the message input menu is now automatically refreshed each time it is accessed, ensuring all users always see the most current set of available tools.
- 🛠️ **Resolved Duplicate Tool Name Collision**: Fixed an issue where tools with identical names from different external servers were silently removed, preventing their simultaneous use. The system now correctly handles tool name collisions by internally prefixing tools with their server identifier, allowing multiple instances of similarly named tools from different servers to be active and usable by LLMs.
- 🖼️ **Resolved Image Generation API Size Parameter Issue**: Fixed a bug where the "/api/v1/images/generations" API endpoint did not correctly apply the 'size' parameter specified in the request payload for image generation. The system now properly honors the requested image dimensions (e.g., '1980x1080'), ensuring that generated images match the user's explicit size preference rather than defaulting to settings.
- 🗄️ **Resolved S3 Vector Upload Limitations**: Fixed an issue that prevented uploading more than 500 vectors to S3 Vector buckets due to API limitations, which resulted in a "ValidationException". S3 vector uploads are now batched in groups of 500, ensuring successful processing of larger datasets.
- 🛠️ **Fixed Tool Installation Error During Startup**: Resolved a "NoneType" error that occurred during tool installation at startup when 'tool.user' was unexpectedly null. The system now includes a check to ensure 'tool.user' exists before attempting to access its properties, preventing crashes and ensuring robust tool initialization.
- 🛠️ **Improved Azure OpenAI GPT-5 Parameter Handling**: Fixed an issue with Azure OpenAI SDK parameter handling to correctly support GPT-5 models. The 'max_tokens' parameter is now appropriately converted to 'max_completion_tokens' for GPT-5 models, ensuring consistent behavior and proper function execution similar to existing o-series models.
- 🐛 **Resolved Exception with Missing Group Permissions**: Fixed an exception that occurred in the access control logic when group permission objects were missing or null. The system now correctly handles cases where groups may not have explicit permission definitions, ensuring that 'None' checks prevent errors and maintain application stability when processing user permissions.
- 🛠️ **Improved OpenAI API Base URL Handling**: Fixed an issue where a trailing slash in the 'OPENAI_API_BASE_URL' configuration could lead to models not being detected or the endpoint failing. The system now automatically removes trailing slashes from the configured URL, ensuring robust and consistent connections to OpenAI-compatible APIs.
- 🖼️ **Resolved S3-Compatible Storage Upload Failures**: Fixed an issue where uploads to S3-compatible storage providers would fail with an "XAmzContentSHA256Mismatch" error. The system now correctly handles checksum calculations, ensuring reliable file and image uploads to S3-compatible services.
- 🌐 **Corrected 'Releases' Link**: Fixed an issue where the 'Releases' button in the user menu directed to an incorrect URL, now correctly linking to the Open WebUI GitHub releases page.
- 🛠️ **Resolved Model Sorting Errors with Null or Undefined Names**: Fixed multiple "TypeError" instances that occurred when attempting to sort model lists where model names were null or undefined. The sorting logic across various UI components (including Ollama model selection, leaderboard, and admin model settings) has been made more robust by gracefully handling absent model names, preventing crashes and ensuring consistent alphabetical sorting based on available name or ID.
- 🎨 **Resolved Banner Dismissal Issue with Iteration IDs**: Fixed a bug where dismissing banners could lead to unintended multiple banner dismissals or other incorrect behavior, especially when banners lacked unique iteration IDs. Unique IDs are now assigned during banner iteration, ensuring proper individual dismissal and consistent display behavior.
### Changed
- 🛂 **Environment Variable for Admin Access Control**: The environment variable "ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" has been renamed to "BYPASS_ADMIN_ACCESS_CONTROL". This new name more accurately reflects its function as a control to allow administrators to bypass model access restrictions. Users are encouraged to update their configurations to use the new variable name; existing configurations using the old name will still be honored for backward compatibility.
- 🗂️ **Core Directory Path Resolution Updated**: The internal mechanism for resolving core application directory paths ("OPEN_WEBUI_DIR", "BACKEND_DIR", "BASE_DIR") has been updated to use canonical resolution via "Path().resolve()". This change improves path reliability but may require adjustments for any external scripts or configurations that previously relied on specific non-canonical path interpretations.
- 🗃️ **Database Performance Options**: New database performance options, "DATABASE_ENABLE_SQLITE_WAL" and "DATABASE_DEDUPLICATE_INTERVAL", are now available. If "DATABASE_ENABLE_SQLITE_WAL" is enabled, SQLite will operate in WAL mode, which may alter SQLite's file locking behavior. If "DATABASE_DEDUPLICATE_INTERVAL" is set to a non-zero value, the "user.last_active_at" timestamp will be updated less frequently, leading to slightly less real-time accuracy for this specific field but significantly reducing database write conflicts and improving overall performance. Both options are disabled by default.
- 🌐 **Renamed Web Search Concurrency Setting**: The environment variable "WEB_SEARCH_CONCURRENT_REQUESTS" has been renamed to "WEB_LOADER_CONCURRENT_REQUESTS". This change clarifies its scope, explicitly applying to the concurrency of the web loader component (which fetches content from search results) rather than the initial search engine query. Users relying on the old environment variable name for configuring web search concurrency must update their configurations to use "WEB_LOADER_CONCURRENT_REQUESTS".
## [0.6.22] - 2025-08-11
### Added
- 🔗 **OpenAI API '/v1' Endpoint Compatibility**: Enhanced API compatibility by supporting requests to paths like '/v1/models', '/v1/embeddings', and '/v1/chat/completions'. This allows Open WebUI to integrate more seamlessly with tools that expect OpenAI's '/v1' API structure.
- 🪄 **Toggle for Guided Response Regeneration Menu**: Introduced a new setting in 'Interface' settings, providing the ability to enable or disable the expanded guided response regeneration menu. This offers users more control over their chat workflow and interface preferences.
- ✨ **General UI/UX Enhancements**: Implemented various user interface and experience improvements, including more rounded corners for cards in the Knowledge, Prompts, and Tools sections, and minor layout adjustments within the chat Navbar for improved visual consistency.
- 🌐 **Localization & Internationalization Improvements**: Introduced support for the Kabyle (Taqbaylit) language, refined and expanded translations for Chinese, expanding the platform's linguistic coverage.
### Fixed
- 🐞 **OpenAI Error Message Propagation**: Resolved an issue where specific OpenAI API errors (e.g., 'Organization Not Verified') were obscured by generic 'JSONResponse' iterable errors. The system now correctly propagates detailed and actionable error messages from OpenAI to the user.
- 🌲 **Pinecone Insert Issue**: Fixed a bug that prevented proper insertion of items into Pinecone vector databases.
- 📦 **S3 Vector Issue**: Resolved a bug where s3vector functionality failed due to incorrect import paths.
- 🏠 **Landing Page Option Setting Not Working**: Fixed an issue where the landing page option in settings was not functioning as intended.
## [0.6.21] - 2025-08-10
### Added
- 👥 **User Groups in Edit Modal**: Added display of user groups information in the user edit modal, allowing administrators to view and manage group memberships directly when editing a user.
### Fixed
- 🐞 **Chat Completion 'model_id' Error**: Resolved a critical issue where chat completions failed with an "undefined model_id" error after upgrading to version 0.6.20, ensuring all models now function correctly and reliably.
- 🛠️ **Audit Log User Information Logging**: Fixed an issue where user information was not being correctly logged in the audit trail due to an unreflected function prototype change, ensuring complete logging for administrative oversight.
- 🛠️ **OpenTelemetry Configuration Consistency**: Fixed an issue where OpenTelemetry metric and log exporters' 'insecure' settings did not correctly default to the general OpenTelemetry 'insecure' flag, ensuring consistent security configurations across all OpenTelemetry exports.
- 📝 **Reply Input Content Display**: Fixed an issue where replying to a message incorrectly displayed '{{INPUT_CONTENT}}' instead of the actual message content, ensuring proper content display in replies.
- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Korean, Spanish and Irish, ensuring a more fluent and native experience for global users.
## [0.6.20] - 2025-08-10
### Fixed
- 🛠️ **Quick Actions "Add" Behavior**: Fixed a bug where using the "Add" button in Quick Actions would add the resulting message as the very first message in the chat, instead of appending it to the latest message.
## [0.6.19] - 2025-08-09
### Added
- ✨ **Modernized Sidebar and Major UI Refinements**: The main navigation sidebar has been completely redesigned with a modern, cleaner aesthetic, featuring a sticky header and footer to keep key controls accessible. Core sidebar logic, like the pinned models list, was also refactored into dedicated components for better performance and maintainability.
- 🪄 **Guided Response Regeneration**: The "Regenerate" button has been transformed into a powerful new menu. You can now guide the AI's next attempt by suggesting changes in a text prompt, or use one-click options like "Try Again," "Add Details," or "More Concise" to instantly refine and reshape the response to better fit your needs.
- 🛠️ **Improved Tool Call Handling for GPT-OSS Models**: Implemented robust handling for tool calls specifically for GPT-OSS models, ensuring proper function execution and integration.
- 🛑 **Stop Button for Merge Responses**: Added a dedicated stop button to immediately halt the generation of merged AI responses, providing users with more control over ongoing outputs.
- 🔄 **Experimental SCIM 2.0 Support**: Implemented SCIM 2.0 (System for Cross-domain Identity Management) protocol support, enabling enterprise-grade automated user and group provisioning from identity providers like Okta, Azure AD, and Google Workspace for seamless user lifecycle management. Configuration is managed securely via environment variables.
- 🗂️ **Amazon S3 Vector Support**: You can now use Amazon S3 Vector as a high-performance vector database for your Retrieval-Augmented Generation (RAG) workflows. This provides a scalable, cloud-native storage option for users deeply integrated into the AWS ecosystem, simplifying infrastructure and enabling enterprise-scale knowledge management.
- 🗄️ **Oracle 23ai Vector Search Support**: Added support for Oracle 23ai's new vector search capabilities as a supported vector database, providing a robust and scalable option for managing large-scale documents and integrating vector search with existing business data at the database level.
- ⚡ **Qdrant Performance and Configuration Enhancements**: The Qdrant client has been significantly improved with faster data retrieval logic for 'get' and 'query' operations. New environment variables ('QDRANT_TIMEOUT', 'QDRANT_HNSW_M') provide administrators with finer control over query timeouts and HNSW index parameters, enabling better performance tuning for large-scale deployments.
- 🔐 **Encrypted SQLite Database with SQLCipher**: You can now encrypt your entire SQLite database at rest using SQLCipher. By setting the 'DATABASE_TYPE' to 'sqlite+sqlcipher' and providing a 'DATABASE_PASSWORD', all data is transparently encrypted, providing an essential security layer for protecting sensitive information in self-hosted deployments. Note that this requires additional system libraries and the 'sqlcipher3-wheels' Python package.
- 🚀 **Efficient Redis Connection Management**: Implemented a shared connection pool cache to reuse Redis connections, dramatically reducing the number of active clients. This prevents connection exhaustion errors, improves performance, and ensures greater stability in high-concurrency deployments and those using Redis Sentinel.
- ⚡ **Batched Response Streaming for High Performance**: Dramatically improve performance and stability during high-speed response streaming by batching multiple tokens together before sending them to the client. A new 'Stream Delta Chunk Size' advanced parameter can be set per-model or in user/chat settings, significantly reducing CPU load on the server, Redis, and client, and preventing connection issues in high-concurrency environments.
- ⚙️ **Global Batched Streaming Configuration**: Administrators can now set a system-wide default for response streaming using the new 'CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE' environment variable. This allows for global performance tuning, while still letting per-model or per-chat settings override the default for more granular control.
- 🔎 **Advanced Chat Search with Status Filters**: Quickly find any conversation with powerful new search filters. You can now instantly narrow down your chats using prefixes like 'pinned:true', 'shared:true', and 'archived:true' directly in the search bar. An intelligent dropdown menu assists you by suggesting available filter options as you type, streamlining your workflow and making chat management more efficient than ever.
- 🛂 **Granular Chat Controls Permissions**: Administrators can now manage chat settings with greater detail. The main "Chat Controls" permission now acts as a master switch, while new granular toggles for "Valves", "System Prompts", and "Advanced Parameters" allow for more specific control over which sections are visible to users inside the panel.
- ✍️ **Formatting Toolbar for Chat Input**: Introduced a dedicated formatting toolbar for the rich text chat input field, providing users with more accessible options for text styling and editing, configurable via interface settings.
- 📑 **Tabbed View for Multi-Model Responses**: You can now enable a new tabbed interface to view responses from multiple models. Instead of side-scrolling cards, this compact view organizes each model's response into its own tab, making it easier to compare outputs and saving vertical space. This feature can be toggled on or off in Interface settings.
- ↕️ **Reorder Pinned Models via Drag-and-Drop**: You can now organize your pinned models in the sidebar by simply dragging and dropping them into your preferred order. This custom layout is saved automatically, giving you more flexible control over your workspace.
- 📌 **Quick Model Unpin Shortcut**: You can now quickly unpin a model by holding the Shift key and hovering over it to reveal an instant unpin button, streamlining your workspace customization.
- ⚡ **Improved Chat Input Performance**: The chat input is now significantly more responsive, especially when pasting or typing large amounts of text. This was achieved by implementing a debounce mechanism for the auto-save feature, which prevents UI lag and ensures a smooth, uninterrupted typing experience.
- ✍️ **Customizable Floating Quick Actions with Tool Support**: Take full control of your text interaction workflow with new customizable floating quick actions. In Settings, you can create, edit, or disable these actions and even integrate tools using the '{{TOOL:tool_id}}' syntax in your prompts, enabling powerful one-click automations on selected text. This is in addition to using placeholders like '{{CONTENT}}' and '{{INPUT_CONTENT}}' for custom text transformations.
- 🔒 **Admin Workspace Privacy Control**: Introduced the 'ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS' environment variable (defaults to 'True') allowing administrators to control their access privileges to workspace items (Knowledge, Models, Prompts, Tools). When disabled, administrators adhere to the same access control rules as regular users, enhancing data separation for multi-tenant deployments.
- 🗄️ **Comprehensive Model Configuration Management**: Administrators can now export the entire model configuration to a file and use a new declarative sync endpoint to manage models in bulk. This powerful feature enables seamless backups, migrations, and state replication across multiple instances.
- 📦 **Native Redis Cluster Mode Support**: Added full support for connecting to Redis in cluster mode, allowing for scalable and highly available Redis deployments beyond Sentinel-managed setups. New environment variables 'REDIS_CLUSTER' and 'WEBSOCKET_REDIS_CLUSTER' enable the use of 'redis.cluster.RedisCluster' clients.
- 📊 **Granular OpenTelemetry Metrics Configuration**: Introduced dedicated environment variables and enhanced configuration options for OpenTelemetry metrics, allowing for separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for metrics export, independent of trace settings. This provides greater flexibility for integrating with diverse observability stacks.
- 🪵 **Granular OpenTelemetry Logging Configuration**: Enhanced the OpenTelemetry logging integration by introducing dedicated environment variables for logs, allowing separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for log export, independent of general OTel settings. The application's default Python logger now leverages this configuration to automatically send logs to your OTel endpoint when enabled via 'ENABLE_OTEL_LOGS'.
- 📁 **Enhanced Folder Chat Management with Sorting and Time Blocks**: The chat list within folders now supports comprehensive sorting options by title and updated time, along with intelligent time-based grouping (e.g., "Today," "Yesterday") similar to the main chat view, making navigation and organization of project-specific conversations significantly easier.
- ⚙️ **Configurable Datalab Marker API & Advanced Processing Options**: Enhanced Datalab Marker API integration, allowing administrators to configure custom API base URLs for self-hosting and to specify comprehensive processing options via a new 'additional_config' JSON parameter. This replaces the deprecated language selection feature and provides granular control over document extraction, with streamlined API endpoint resolution for more robust self-hosted deployments.
- 🧑‍💼 **Export All Users to CSV**: Administrators can now export a complete list of all users to a CSV file directly from the Admin Panel's database settings. This provides a simple, one-click way to generate user data for auditing, reporting, or management purposes.
- 🛂 **Customizable OAuth 'sub' Claim**: Administrators can now use the 'OAUTH_SUB_CLAIM_OVERRIDE' environment variable to specify which claim from the identity provider should be used as the unique user identifier ('sub'). This provides greater flexibility and control for complex enterprise authentication setups where modifying the IDP's default claims is not possible.
- 👁️ **Password Visibility Toggle for Input Fields**: Password fields across the application (login, registration, user management, and account settings) now utilize a new 'SensitiveInput' component, providing a consistent toggle to reveal/hide passwords for improved usability and security.
- 🛂 **Optional "Confirm Password" on Sign-Up**: To help prevent password typos during account creation, administrators can now enable a "Confirm Password" field on the sign-up page. This feature is disabled by default and can be activated via an environment variable for enhanced user experience.
- 💬 **View Full Chat from User Feedback**: Administrators can now easily navigate to the full conversation associated with a user feedback entry directly from the feedback modal, streamlining the review and troubleshooting process.
- 🎚️ **Intuitive Hybrid Search BM25-Weight Slider**: The numerical input for the BM25-Weight parameter in Hybrid Search has been replaced with an interactive slider, offering a more intuitive way to adjust the balance between lexical and semantic search. A "Default/Custom" toggle and clearer labels enhance usability and understanding of this key parameter.
- ⚙️ **Enhanced Bulk Function Synchronization**: The API endpoint for synchronizing functions has been significantly improved to reliably handle bulk updates. This ensures that importing and managing large libraries of functions is more robust and error-free for administrators.
- 🖼️ **Option to Disable Image Compression in Channels**: Introduced a new setting under Interface options to allow users to force-disable image compression specifically for images posted in channels, ensuring higher resolution for critical visual content.
- 🔗 **Custom CORS Scheme Support**: Introduced a new environment variable 'CORS_ALLOW_CUSTOM_SCHEME' that allows administrators to define custom URL schemes (e.g., 'app://') for CORS origins, enabling greater flexibility for local development or desktop client integrations.
- ♿ **Translatable and Accessible Banners**: Enhanced banner elements with translatable badge text and proper ARIA attributes (aria-label, aria-hidden) for SVG icons, significantly improving accessibility and screen reader compatibility.
- ⚠️ **OAuth Configuration Warning for Missing OPENID_PROVIDER_URL**: Added a proactive startup warning that notifies administrators when OAuth providers (Google, Microsoft, or GitHub) are configured but the essential 'OPENID_PROVIDER_URL' environment variable is missing. This prevents silent OAuth logout failures and guides administrators to complete their setup correctly.
- ♿ **Major Accessibility Enhancements**: Key parts of the interface have been made significantly more accessible. The user profile menu is now fully navigable via keyboard, essential controls in the Playground now include proper ARIA labels for screen readers, and decorative images have been hidden from assistive technologies to reduce audio clutter. Menu buttons also feature enhanced accessibility with 'aria-label', 'aria-hidden' for SVGs, and 'aria-pressed' for toggle buttons.
- ⚙️ **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users, including refining logging output to be cleaner and more efficient by conditionally including 'extra_json' fields and improving consistent metadata handling in vector database operations, and laying preliminary scaffolding for future analytics features.
- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Danish, Korean, Persian, Polish, Simplified Chinese, and Spanish, ensuring a more fluent and native experience for global users across all supported languages.
### Fixed
- 🛡️ **Hardened Channel Message Security**: Fixed a key permission flaw that allowed users with channel access to edit or delete messages belonging to others. The system now correctly enforces that users can only modify their own messages, protecting data integrity in shared channels.
- 🛡️ **Hardened OAuth Security by Removing JWT from URL**: Fixed a critical security vulnerability where the authentication token was exposed in the URL after a successful OAuth login. The token is now transferred via a browser cookie, preventing potential leaks through browser history or server logs and protecting user sessions.
- 🛡️ **Hardened Chat Completion API Security**: The chat completion API endpoint now includes an explicit ownership check, ensuring non-admin users cannot access chats that do not belong to them and preventing potential unauthorized access.
- 🛠️ **Resilient Model Loading**: Fixed an issue where a failure in loading the model list (e.g., from a misconfigured provider) would prevent the entire user interface, including the admin panel, from loading. The application now gracefully handles these errors, ensuring the UI remains accessible.
- 🔒 **Resolved FIPS Self-Test Failure**: Fixed a critical issue that prevented Open WebUI from running on FIPS-compliant systems, specifically resolving the "FATAL FIPS SELFTEST FAILURE" error related to OpenSSL and SentenceTransformers, restoring compatibility with secure environments.
- 📦 **Redis Cluster Connection Restored**: Fixed an issue where the backend was unable to connect to Redis in cluster mode, now ensuring seamless integration with scalable Redis cluster deployments.
- 📦 **PGVector Connection Stability**: Fixed an issue where read-only operations could leave database transactions idle, preventing potential connection errors and improving overall database stability and resource management.
- 🛠️ **OpenAPI Tool Integration for Array Parameters Fixed**: Resolved a critical bug where external tools using array parameters (e.g., for tags) would fail when used with OpenAI models. The system now correctly generates the required 'items' property in the function schema, restoring functionality and preventing '400 Bad Request' errors.
- 🛠️ **Tool Creation for Users Restored**: Fixed a bug in the code editor where status messages were incorrectly prepended to tool scripts, causing a syntax error upon saving. All authorized users can now reliably create and save new tools.
- 📁 **Folder Knowledge Processing Restored**: Fixed a bug where files uploaded to folder and model knowledge bases were not being extracted or analyzed for Retrieval-Augmented Generation (RAG) when the 'Max Upload Count' setting was empty, ensuring seamless document processing and knowledge augmentation.
- 🧠 **Custom Model Knowledge Base Updates Recognized**: Fixed a bug where custom models linked to to knowledge bases did not automatically recognize newly added files to those knowledge bases. Models now correctly incorporate the latest information from updated knowledge collections.
- 📦 **Comprehensive Redis Key Prefixing**: Corrected hardcoded prefixes to ensure the REDIS_KEY_PREFIX is now respected across all WebSocket and task management keys. This prevents data collisions in multi-instance deployments and improves compatibility with Redis cluster mode.
- ✨ **More Descriptive OpenAI Router Errors**: The OpenAI-compatible API router now propagates detailed upstream error messages instead of returning a generic 'Bad Request'. This provides clear, actionable feedback for developers and API users, making it significantly easier to debug and resolve issues with model requests.
- 🔐 **Hardened OIDC Signout Flow**: The OpenID Connect signout process now verifies that the 'OPENID_PROVIDER_URL' is configured before attempting to communicate with it, preventing potential errors and ensuring a more reliable logout experience.
- 🍓 **Raspberry Pi Compatibility Restored**: Pinned the pyarrow library to version 20.0.0, resolving an "Illegal Instruction" crash on ARM-based devices like the Raspberry Pi and ensuring stable operation on this hardware.
- 📁 **Folder System Prompt Variables Restored**: Fixed a bug where prompt variables (e.g., '{{CURRENT_DATETIME}}') were not being rendered in Folder-level System Prompts. This restores an important capability for creating dynamic, context-aware instructions for all chats within a project folder.
- 📝 **Note Access in Knowledge Retrieval Fixed**: Corrected a permission oversight in knowledge retrieval, ensuring users can always use their own notes as a source for RAG without needing explicit sharing permissions.
- 🤖 **Title Generation Compatibility for GPT-5 Models**: Added support for 'gpt-5' models in the payload handler, which correctly converts the deprecated 'max_tokens' parameter to 'max_completion_tokens'. This resolves title generation failures and ensures seamless operation with the latest generation of models.
- ⚙️ **Correct API 'finish_reason' in Streaming Responses**: Fixed an issue where intermediate 'reasoning_content' chunks in streaming API responses incorrectly reported a 'finish_reason' of 'stop'. The 'finish_reason' is now correctly set to 'null' for these chunks, ensuring compatibility with third-party applications that rely on this field.
- 📈 **Evaluation Pages Stability**: Resolved a crash on the Leaderboard and Feedbacks pages when processing legacy feedback entries that were missing a 'rating' field. The system now gracefully handles this older data, ensuring both pages load reliably for all users.
- 🤝 **Reliable Collaborative Session Cleanup**: Fixed an asynchronous bug in the real-time collaboration engine that prevented document sessions from being properly cleaned up after all users had left. This ensures greater stability and resource management for features like Collaborative Notes.
- 🧠 **Enhanced Memory Stability and Security**: Refactored memory update and delete operations to strictly enforce user ownership, preventing potential data integrity issues. Additionally, improved error handling for memory queries now provides clearer feedback when no memories exists.
- 🧑‍⚖️ **Restored Admin Access to User Feedback**: Fixed a permission issue that blocked administrators from viewing or editing user feedback they didn't create, ensuring they can properly manage all evaluations across the platform.
- 🔐 **PGVector Encryption Fix for Metadata**: Corrected a SQL syntax error in the experimental 'PGVECTOR_PGCRYPTO' feature that prevented encrypted metadata from being saved. Document uploads to encrypted PGVector collections now work as intended.
- 🔍 **Serply Web Search Integration Restored**: Fixed an issue where incorrect parameters were passed to the Serply web search engine, restoring its functionality for RAG and web search workflows.
- 🔍 **Resilient Web Search Processing**: Web search retrieval now gracefully handles search results that are missing a 'snippet', preventing crashes and ensuring that RAG workflows complete successfully even with incomplete data from search engines.
- 🖼️ **Table Pasting in Rich Text Input Displayed Correctly**: Fixed an issue where pasting table text into the rich text input would incorrectly display it as code. Tables are now properly rendered as expected, improving content formatting and user experience.
- ✍️ **Rich Text Input TypeError Resolution**: Addressed a potential 'TypeError: ue.getWordAtDocPos is not a function' in 'MessageInput.svelte' by refactoring how the 'getWordAtDocPos' function is accessed and referenced from 'RichTextInput.svelte', ensuring stable rich text input behavior, especially after production restarts.
- ✏️ **Manual Code Block Creation in Chat Restored**: Fixed an issue where typing three backticks and then pressing Shift+Enter would incorrectly remove the backticks when "Enter to Send" mode was active. This ensures users can reliably create multi-line code blocks manually.
- 🎨 **Consistent Dark Mode Background**: Fixed an issue where the application background could incorrectly flash or remain white during page loads and refreshes in dark mode, ensuring a seamless and consistent visual experience.
- 🎨 **'Her' Theme Rendering Fixed**: Corrected a bug that caused the "Her" theme to incorrectly render as a dark theme in some situations. The theme now reliably applies its intended light appearance across all sessions.
- 📜 **Corrected Markdown Table Line Break Rendering**: Fixed an issue where line breaks ('<br>') within Markdown tables were displayed as raw HTML instead of being rendered correctly. This ensures that tables with multi-line cell content are now displayed as intended.
- 🚦 **Corrected App Configuration for Pending Users**: Fixed an issue where users awaiting approval could incorrectly load the full application interface, leading to a confusing or broken UI. This ensures that only fully approved users receive the standard app 'config', resulting in a smoother and more reliable onboarding experience.
- 🔄 **Chat Cloning Now Includes Tags, Folder Status, and Pinned Status**: When cloning a chat or shared chat, its associated tags, folder organization, and pinned status are now correctly replicated, ensuring consistent chat management.
- ⚙️ **Enhanced Backend Reliability**: Resolved a potential crash in knowledge base retrieval when referencing a deleted note. Additionally, chat processing was refactored to ensure model information is saved more reliably, enhancing overall system stability.
- ⚙️ **Floating 'Ask/Explain' Modal Stability**: Fixed an issue that spammed the console with errors when navigating away while a model was generating a response in the floating 'Ask' or 'Explain' modals. In-flight requests are now properly cancelled, improving application stability.
- ⚡ **Optimized User Count Checks**: Improved performance for user count and existence checks across the application by replacing resource-intensive 'COUNT' queries with more efficient 'EXISTS' queries, reducing database load.
- 🔐 **Hardened OpenTelemetry Exporter Configuration**: The OTLP HTTP exporter no longer uses a potentially insecure explicit flag, improving security by relying on the connection URL's protocol (HTTP/HTTPS) to ensure transport safety.
- 📱 **Mobile User Menu Closing Behavior Fixed**: Resolved an issue where the user menu would remain open on mobile devices after selecting an option, ensuring the menu correctly closes and returns focus to the main interface for a smoother mobile experience.
- 📱 **OnBoarding Page Display Fixed on Mobile**: Resolved an issue where buttons on the OnBoarding page were not consistently visible on certain mobile browsers, ensuring a functional and complete user experience across devices.
- ↕️ **Improved Pinned Models Drag-and-Drop Behavior**: The drag-and-drop functionality for reordering pinned models is now explicitly disabled on mobile devices, ensuring better usability and preventing potential UI conflicts or unexpected behavior.
- 📱 **PWA Rotation Behavior Corrected**: The Progressive Web App now correctly respects the device's screen orientation lock, preventing unwanted rotation and ensuring a more native mobile experience.
- ✏️ **Improved Chat Title Editing Behavior**: Changes to a chat title are now reliably saved when the user clicks away or presses Enter, replacing a less intuitive behavior that could accidentally discard edits. This makes renaming chats a smoother and more predictable experience.
- ✏️ **Underscores Allowed in Prompt Commands**: Fixed the validation for prompt commands to correctly allow the use of underscores ('\_'), aligning with documentation examples and improving flexibility in naming custom prompts.
- 💡 **Title Generation Button Behavior Fixed**: Resolved an issue where clicking the "Generate Title" button while editing a chat or note title would incorrectly save the title before generation could start. The focus is now managed correctly, ensuring a smooth and predictable user experience.
- ✏️ **Consistent Chat Input Height**: Fixed a minor visual bug where the chat input field's height would change slightly when toggling the "Rich Text Input for Chat" setting, ensuring a more stable and consistent layout.
- 🙈 **Admin UI Toggle Stability**: Fixed a visual glitch in the Admin settings where toggle switches could briefly display an incorrect state on page load, ensuring the UI always accurately reflects the saved settings.
- 🙈 **Community Sharing Button Visibility**: The "Share to Community" button on the feedback page is now correctly hidden when the Enable Community Sharing feature is disabled in the admin settings, ensuring the UI respects the configured sharing policy.
- 🙈 **"Help Us Translate" Link Visibility**: The "Help us translate" link in settings is now correctly hidden in deployments with specific license configurations, ensuring a cleaner interface for enterprise users.
- 🔗 **Robust Tool Server URL Handling**: Fixed an issue where providing a full URL for a tool server's OpenAPI specification resulted in an invalid path. The system now correctly handles both absolute URLs and relative paths, improving configuration flexibility.
- 🔧 **Improved Azure URL Detection**: The logic for identifying Azure OpenAI endpoints has been made more robust, ensuring all valid Azure URLs are now correctly detected for a smoother connection setup.
- ⚙️ **Corrected Direct Connection Save Logic**: Fixed a bug in the Admin Connections settings page by removing a redundant save action for 'Direct Connections', leading to more reliable and predictable behavior when updating settings.
- 🔗 **Corrected "Discover" Links**: The "Discover" links for models, prompts, tools, and functions now point to their specific, relevant pages on openwebui.com, improving content discovery for users.
- ⏱️ **Refined Display of AI Thought Duration**: Adjusted the display logic for AI thought (reasoning) durations to more accurately show very short thought times as "less than a second," improving clarity in AI process feedback.
- 📜 **Markdown Line Break Rendering Refinement**: Improved handling of line breaks within Markdown rendering for better visual consistency.
- 🛠️ **Corrected OpenTelemetry Docker Compose Example**: The docker-compose.otel.yaml file has been fixed and enhanced by removing duplicates, adding necessary environment variables, and hardening security settings, ensuring a more reliable out-of-box observability setup.
- 🛠️ **Development Script CORS Fix**: Corrected the CORS origin URL in the local development script (dev.sh) by removing the trailing slash, ensuring a more reliable and consistent setup for developers.
- ⬆️ **OpenTelemetry Libraries Updated**: Upgraded all OpenTelemetry-related libraries to their latest versions, ensuring better performance, stability, and compatibility for observability.
### Changed
- ❗ **Docling Integration Upgraded to v1 API (Breaking Change)**: The integration with the Docling document processing engine has been updated to its new, stable '/v1' API. This is required for compatibility with Docling version 1.0.0 and newer. As a result, older versions of Docling are no longer supported. Users who rely on Docling for document ingestion **must upgrade** their docling-serve instance to ensure continued functionality.
- 🗣️ **Admin-First Whisper Language Priority**: The global WHISPER_LANGUAGE setting now acts as a strict override for audio transcriptions. If set, it will be used for all speech-to-text tasks, ignoring any language specified by the user on a per-request basis. This gives administrators more control over transcription consistency.
- ✂️ **Datalab Marker API Language Selection Removed**: The separate language selection option for the Datalab Marker API has been removed, as its functionality is now integrated and superseded by the more comprehensive 'additional_config' parameter. Users should transition to using 'additional_config' for relevant language and processing settings.
- 📄 **Documentation and Releases Links Visibility**: The "Documentation" and "Releases" links in the user menu are now visible only to admin users, streamlining the user interface for non-admin roles.
## [0.6.18] - 2025-07-19
### Fixed
- 🚑 **Users Not Loading in Groups**: Resolved an issue where user list was not displaying within user groups, restoring full visibility and management of group memberships for teams and admins.
## [0.6.17] - 2025-07-19
### Added
+37 -25
View File
@@ -3,6 +3,8 @@
# use build args in the docker build command with --build-arg="BUILDARG=true"
ARG USE_CUDA=false
ARG USE_OLLAMA=false
ARG USE_SLIM=false
ARG USE_PERMISSION_HARDENING=false
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
ARG USE_CUDA_VER=cu128
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
@@ -24,6 +26,9 @@ ARG GID=0
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
ARG BUILD_HASH
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
# ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /app
# to store git revision in build
@@ -43,6 +48,8 @@ FROM python:3.11-slim-bookworm AS base
ARG USE_CUDA
ARG USE_OLLAMA
ARG USE_CUDA_VER
ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG UID
@@ -54,6 +61,7 @@ ENV ENV=prod \
# pass build args to the build
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
USE_CUDA_DOCKER=${USE_CUDA} \
USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
@@ -108,29 +116,13 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
# Make sure the user has access to the app and root directory
RUN chown -R $UID:$GID /app $HOME
RUN if [ "$USE_OLLAMA" = "true" ]; then \
apt-get update && \
# Install pandoc and netcat
apt-get install -y --no-install-recommends git build-essential pandoc netcat-openbsd curl && \
apt-get install -y --no-install-recommends gcc python3-dev && \
# for RAG OCR
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
# install helper tools
apt-get install -y --no-install-recommends curl jq && \
# install ollama
curl -fsSL https://ollama.com/install.sh | sh && \
# cleanup
rm -rf /var/lib/apt/lists/*; \
else \
apt-get update && \
# Install pandoc, netcat and gcc
apt-get install -y --no-install-recommends git build-essential pandoc gcc netcat-openbsd curl jq && \
apt-get install -y --no-install-recommends gcc python3-dev && \
# for RAG OCR
apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
# cleanup
rm -rf /var/lib/apt/lists/*; \
fi
# Install common system dependencies
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 \
&& rm -rf /var/lib/apt/lists/*
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
@@ -146,13 +138,22 @@ RUN pip3 install --no-cache-dir uv && \
else \
pip3 install torch 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 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'])"; \
fi; \
chown -R $UID:$GID /app/backend/data/
fi; \
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
rm -rf /var/lib/apt/lists/*;
# Install Ollama if requested
RUN if [ "$USE_OLLAMA" = "true" ]; then \
date +%s > /tmp/ollama_build_hash && \
echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
curl -fsSL https://ollama.com/install.sh | sh && \
rm -rf /var/lib/apt/lists/*; \
fi
# copy embedding weight from build
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
@@ -170,6 +171,17 @@ EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
# - Group 0 owns /app and /root
# - Directories are group-writable and have SGID so new files inherit GID 0
RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
set -eux; \
chgrp -R 0 /app /root || true; \
chmod -R g+rwX /app /root || true; \
find /app -type d -exec chmod g+s {} + || true; \
find /root -type d -exec chmod g+s {} + || true; \
fi
USER $UID:$GID
ARG BUILD_HASH
+14 -2
View File
@@ -31,6 +31,8 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🔄 **SCIM 2.0 Support**: Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
@@ -68,7 +70,7 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
#### Emerald
<table>
<tr>
<!-- <tr>
<td>
<a href="https://n8n.io/" target="_blank">
<img src="https://docs.openwebui.com/sponsors/logos/n8n.png" alt="n8n" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
@@ -77,7 +79,7 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
<td>
<a href="https://n8n.io/">n8n</a> • Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
</td>
</tr>
</tr> -->
<tr>
<td>
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs" target="_blank">
@@ -88,6 +90,16 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
<a href="https://tailscale.com/blog/self-host-a-local-ai-stack/?utm_source=OpenWebUI&utm_medium=paid-ad-placement&utm_campaign=OpenWebUI-Docs">Tailscale</a> • Connect self-hosted AI to any device with Tailscale
</td>
</tr>
<tr>
<td>
<a href="https://warp.dev/open-webui" target="_blank">
<img src="https://docs.openwebui.com/sponsors/logos/warp.png" alt="Warp" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
</a>
</td>
<td>
<a href="https://warp.dev/open-webui">Warp</a> • The intelligent terminal for developers
</td>
</tr>
</table>
---
+2 -1
View File
@@ -1,2 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173"
PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
+1 -1
View File
@@ -10,7 +10,7 @@ script_location = migrations
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
prepend_sys_path = ..
# timezone to use when rendering the date within the migration file
# as well as the filename.
+206 -21
View File
@@ -7,7 +7,7 @@ import redis
from datetime import datetime
from pathlib import Path
from typing import Generic, Optional, TypeVar
from typing import Generic, Union, Optional, TypeVar
from urllib.parse import urlparse
import requests
@@ -168,9 +168,19 @@ class PersistentConfig(Generic[T]):
self.config_path = config_path
self.env_value = env_value
self.config_value = get_config_value(config_path)
if self.config_value is not None and ENABLE_PERSISTENT_CONFIG:
log.info(f"'{env_name}' loaded from the latest database entry")
self.value = self.config_value
if (
self.config_path.startswith("oauth.")
and not ENABLE_OAUTH_PERSISTENT_CONFIG
):
log.info(
f"Skipping loading of '{env_name}' as OAuth persistent config is disabled"
)
self.value = env_value
else:
log.info(f"'{env_name}' loaded from the latest database entry")
self.value = self.config_value
else:
self.value = env_value
@@ -213,13 +223,14 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str
def __init__(
self,
redis_url: Optional[str] = None,
redis_sentinels: Optional[list] = [],
redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui",
):
super().__setattr__("_state", {})
@@ -227,7 +238,12 @@ class AppConfig:
if redis_url:
super().__setattr__(
"_redis",
get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
get_redis_connection(
redis_url,
redis_sentinels,
redis_cluster,
decode_responses=True,
),
)
def __setattr__(self, key, value):
@@ -296,6 +312,9 @@ JWT_EXPIRES_IN = PersistentConfig(
# OAuth config
####################################
ENABLE_OAUTH_PERSISTENT_CONFIG = (
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true"
)
ENABLE_OAUTH_SIGNUP = PersistentConfig(
"ENABLE_OAUTH_SIGNUP",
@@ -463,6 +482,12 @@ OAUTH_PROVIDER_NAME = PersistentConfig(
os.environ.get("OAUTH_PROVIDER_NAME", "SSO"),
)
OAUTH_SUB_CLAIM = PersistentConfig(
"OAUTH_SUB_CLAIM",
"oauth.oidc.sub_claim",
os.environ.get("OAUTH_SUB_CLAIM", None),
)
OAUTH_USERNAME_CLAIM = PersistentConfig(
"OAUTH_USERNAME_CLAIM",
"oauth.oidc.username_claim",
@@ -485,7 +510,7 @@ OAUTH_EMAIL_CLAIM = PersistentConfig(
OAUTH_GROUPS_CLAIM = PersistentConfig(
"OAUTH_GROUPS_CLAIM",
"oauth.oidc.group_claim",
os.environ.get("OAUTH_GROUP_CLAIM", "groups"),
os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
)
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
@@ -635,7 +660,7 @@ def load_oauth_providers():
if (
OAUTH_CLIENT_ID.value
and OAUTH_CLIENT_SECRET.value
and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value)
and OPENID_PROVIDER_URL.value
):
@@ -680,6 +705,23 @@ def load_oauth_providers():
"register": oidc_oauth_register,
}
configured_providers = []
if GOOGLE_CLIENT_ID.value:
configured_providers.append("Google")
if MICROSOFT_CLIENT_ID.value:
configured_providers.append("Microsoft")
if GITHUB_CLIENT_ID.value:
configured_providers.append("GitHub")
if configured_providers and not OPENID_PROVIDER_URL.value:
provider_list = ", ".join(configured_providers)
log.warning(
f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!"
)
log.warning(
f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality."
)
load_oauth_providers()
@@ -779,12 +821,6 @@ if CUSTOM_NAME:
pass
####################################
# LICENSE_KEY
####################################
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
####################################
# STORAGE PROVIDER
####################################
@@ -835,7 +871,7 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
ENABLE_DIRECT_CONNECTIONS = PersistentConfig(
"ENABLE_DIRECT_CONNECTIONS",
"direct.enable",
os.environ.get("ENABLE_DIRECT_CONNECTIONS", "True").lower() == "true",
os.environ.get("ENABLE_DIRECT_CONNECTIONS", "False").lower() == "true",
)
####################################
@@ -917,6 +953,9 @@ GEMINI_API_BASE_URL = os.environ.get("GEMINI_API_BASE_URL", "")
if OPENAI_API_BASE_URL == "":
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
else:
if OPENAI_API_BASE_URL.endswith("/"):
OPENAI_API_BASE_URL = OPENAI_API_BASE_URL[:-1]
OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "")
OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY
@@ -1149,10 +1188,18 @@ USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_VALVES = (
os.environ.get("USER_PERMISSIONS_CHAT_VALVES", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = (
os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_PARAMS = (
os.environ.get("USER_PERMISSIONS_CHAT_PARAMS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
@@ -1161,6 +1208,23 @@ USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE_MESSAGE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE_MESSAGE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE", "True").lower()
== "true"
)
USER_PERMISSIONS_CHAT_RATE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_RATE_RESPONSE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_EDIT = (
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
)
@@ -1238,9 +1302,15 @@ DEFAULT_USER_PERMISSIONS = {
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"valves": USER_PERMISSIONS_CHAT_VALVES,
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
"regenerate_response": USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE,
"rate_response": USER_PERMISSIONS_CHAT_RATE_RESPONSE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"share": USER_PERMISSIONS_CHAT_SHARE,
"export": USER_PERMISSIONS_CHAT_EXPORT,
@@ -1305,6 +1375,18 @@ WEBHOOK_URL = PersistentConfig(
ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true"
ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = (
os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true"
)
BYPASS_ADMIN_ACCESS_CONTROL = (
os.environ.get(
"BYPASS_ADMIN_ACCESS_CONTROL",
os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True"),
).lower()
== "true"
)
ENABLE_ADMIN_CHAT_ACCESS = (
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
)
@@ -1343,10 +1425,11 @@ if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
def validate_cors_origin(origin):
parsed_url = urlparse(origin)
# Check if the scheme is either http or https
if parsed_url.scheme not in ["http", "https"]:
# Check if the scheme is either http or https, or a custom scheme
schemes = ["http", "https"] + CORS_ALLOW_CUSTOM_SCHEME
if parsed_url.scheme not in schemes:
raise ValueError(
f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' are allowed."
f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' and CORS_ALLOW_CUSTOM_SCHEME are allowed."
)
# Ensure that the netloc (domain + port) is present, indicating it's a valid URL
@@ -1361,6 +1444,11 @@ def validate_cors_origin(origin):
# in your .env file depending on your frontend port, 5173 in this case.
CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
# Allows custom URL schemes (e.g., app://) to be used as origins for CORS.
# Useful for local development or desktop clients with schemes like app:// or other custom protocols.
# Provide a semicolon-separated list of allowed schemes in the environment variable CORS_ALLOW_CUSTOM_SCHEMES.
CORS_ALLOW_CUSTOM_SCHEME = os.environ.get("CORS_ALLOW_CUSTOM_SCHEME", "").split(";")
if CORS_ALLOW_ORIGIN == ["*"]:
log.warning(
"\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n"
@@ -1801,6 +1889,11 @@ CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig(
),
)
CODE_INTERPRETER_BLOCKED_MODULES = [
library.strip()
for library in os.environ.get("CODE_INTERPRETER_BLOCKED_MODULES", "").split(",")
if library.strip()
]
DEFAULT_CODE_INTERPRETER_PROMPT = """
#### Tools Available
@@ -1868,6 +1961,8 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true"
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
QDRANT_TIMEOUT = int(os.environ.get("QDRANT_TIMEOUT", "5"))
QDRANT_HNSW_M = int(os.environ.get("QDRANT_HNSW_M", "16"))
ENABLE_QDRANT_MULTITENANCY_MODE = (
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
)
@@ -1903,6 +1998,9 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
PGVECTOR_CREATE_EXTENSION = (
os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true"
)
PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true"
PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None)
if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
@@ -1957,6 +2055,37 @@ PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024
PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine")
PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure"
# ORACLE23AI (Oracle23ai Vector Search)
ORACLE_DB_USE_WALLET = os.environ.get("ORACLE_DB_USE_WALLET", "false").lower() == "true"
ORACLE_DB_USER = os.environ.get("ORACLE_DB_USER", None) #
ORACLE_DB_PASSWORD = os.environ.get("ORACLE_DB_PASSWORD", None) #
ORACLE_DB_DSN = os.environ.get("ORACLE_DB_DSN", None) #
ORACLE_WALLET_DIR = os.environ.get("ORACLE_WALLET_DIR", None)
ORACLE_WALLET_PASSWORD = os.environ.get("ORACLE_WALLET_PASSWORD", None)
ORACLE_VECTOR_LENGTH = os.environ.get("ORACLE_VECTOR_LENGTH", 768)
ORACLE_DB_POOL_MIN = int(os.environ.get("ORACLE_DB_POOL_MIN", 2))
ORACLE_DB_POOL_MAX = int(os.environ.get("ORACLE_DB_POOL_MAX", 10))
ORACLE_DB_POOL_INCREMENT = int(os.environ.get("ORACLE_DB_POOL_INCREMENT", 1))
if VECTOR_DB == "oracle23ai":
if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN:
raise ValueError(
"Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN."
)
if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD):
raise ValueError(
"Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication."
)
log.info(f"VECTOR_DB: {VECTOR_DB}")
# S3 Vector
S3_VECTOR_BUCKET_NAME = os.environ.get("S3_VECTOR_BUCKET_NAME", None)
S3_VECTOR_REGION = os.environ.get("S3_VECTOR_REGION", None)
####################################
# Information Retrieval (RAG)
####################################
@@ -2018,10 +2147,16 @@ DATALAB_MARKER_API_KEY = PersistentConfig(
os.environ.get("DATALAB_MARKER_API_KEY", ""),
)
DATALAB_MARKER_LANGS = PersistentConfig(
"DATALAB_MARKER_LANGS",
"rag.datalab_marker_langs",
os.environ.get("DATALAB_MARKER_LANGS", ""),
DATALAB_MARKER_API_BASE_URL = PersistentConfig(
"DATALAB_MARKER_API_BASE_URL",
"rag.datalab_marker_api_base_url",
os.environ.get("DATALAB_MARKER_API_BASE_URL", ""),
)
DATALAB_MARKER_ADDITIONAL_CONFIG = PersistentConfig(
"DATALAB_MARKER_ADDITIONAL_CONFIG",
"rag.datalab_marker_additional_config",
os.environ.get("DATALAB_MARKER_ADDITIONAL_CONFIG", ""),
)
DATALAB_MARKER_USE_LLM = PersistentConfig(
@@ -2061,6 +2196,12 @@ DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig(
== "true",
)
DATALAB_MARKER_FORMAT_LINES = PersistentConfig(
"DATALAB_MARKER_FORMAT_LINES",
"rag.datalab_marker_format_lines",
os.environ.get("DATALAB_MARKER_FORMAT_LINES", "false").lower() == "true",
)
DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig(
"DATALAB_MARKER_OUTPUT_FORMAT",
"rag.datalab_marker_output_format",
@@ -2091,6 +2232,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCLING_DO_OCR = PersistentConfig(
"DOCLING_DO_OCR",
"rag.docling_do_ocr",
os.getenv("DOCLING_DO_OCR", "True").lower() == "true",
)
DOCLING_FORCE_OCR = PersistentConfig(
"DOCLING_FORCE_OCR",
"rag.docling_force_ocr",
os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true",
)
DOCLING_OCR_ENGINE = PersistentConfig(
"DOCLING_OCR_ENGINE",
"rag.docling_ocr_engine",
@@ -2103,6 +2256,24 @@ DOCLING_OCR_LANG = PersistentConfig(
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
)
DOCLING_PDF_BACKEND = PersistentConfig(
"DOCLING_PDF_BACKEND",
"rag.docling_pdf_backend",
os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"),
)
DOCLING_TABLE_MODE = PersistentConfig(
"DOCLING_TABLE_MODE",
"rag.docling_table_mode",
os.getenv("DOCLING_TABLE_MODE", "accurate"),
)
DOCLING_PIPELINE = PersistentConfig(
"DOCLING_PIPELINE",
"rag.docling_pipeline",
os.getenv("DOCLING_PIPELINE", "standard"),
)
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
"DOCLING_DO_PICTURE_DESCRIPTION",
"rag.docling_do_picture_description",
@@ -2510,6 +2681,14 @@ WEB_LOADER_ENGINE = PersistentConfig(
os.environ.get("WEB_LOADER_ENGINE", ""),
)
WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
"WEB_LOADER_CONCURRENT_REQUESTS",
"rag.web.loader.concurrent_requests",
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
)
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
"rag.web.loader.ssl_verification",
@@ -2951,6 +3130,12 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
"image_generation.openai.api_base_url",
os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL),
)
IMAGES_OPENAI_API_VERSION = PersistentConfig(
"IMAGES_OPENAI_API_VERSION",
"image_generation.openai.api_version",
os.getenv("IMAGES_OPENAI_API_VERSION", ""),
)
IMAGES_OPENAI_API_KEY = PersistentConfig(
"IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_key",
+165 -7
View File
@@ -7,6 +7,7 @@ import sys
import shutil
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
import markdown
from bs4 import BeautifulSoup
@@ -16,14 +17,17 @@ from open_webui.constants import ERROR_MESSAGES
# Load .env file
####################################
OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file
print(OPEN_WEBUI_DIR)
# Use .resolve() to get the canonical path, removing any '..' or '.' components
ENV_FILE_PATH = Path(__file__).resolve()
BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file
BASE_DIR = BACKEND_DIR.parent # the path containing the backend/
# OPEN_WEBUI_DIR should be the directory where env.py resides (open_webui/)
OPEN_WEBUI_DIR = ENV_FILE_PATH.parent
print(BACKEND_DIR)
print(BASE_DIR)
# BACKEND_DIR is the parent of OPEN_WEBUI_DIR (backend/)
BACKEND_DIR = OPEN_WEBUI_DIR.parent
# BASE_DIR is the parent of BACKEND_DIR (open-webui-dev/)
BASE_DIR = BACKEND_DIR.parent
try:
from dotenv import find_dotenv, load_dotenv
@@ -287,6 +291,9 @@ DB_VARS = {
if all(DB_VARS.values()):
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"):
# Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set
DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db"
# Replace the postgres:// with postgresql://
if "postgres://" in DATABASE_URL:
@@ -332,6 +339,21 @@ else:
except Exception:
DATABASE_POOL_RECYCLE = 3600
DATABASE_ENABLE_SQLITE_WAL = (
os.environ.get("DATABASE_ENABLE_SQLITE_WAL", "False").lower() == "true"
)
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get(
"DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL", None
)
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
try:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
)
except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
@@ -340,12 +362,17 @@ ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
####################################
# REDIS
####################################
REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true"
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
@@ -377,6 +404,14 @@ except ValueError:
####################################
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
ENABLE_INITIAL_ADMIN_SIGNUP = (
os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true"
)
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
)
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
@@ -430,6 +465,54 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
)
####################################
# OAUTH Configuration
####################################
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
)
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
####################################
# SCIM Configuration
####################################
SCIM_ENABLED = os.environ.get("SCIM_ENABLED", "False").lower() == "true"
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
####################################
# LICENSE_KEY
####################################
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
LICENSE_BLOB = None
LICENSE_BLOB_PATH = os.environ.get("LICENSE_BLOB_PATH", DATA_DIR / "l.data")
if LICENSE_BLOB_PATH and os.path.exists(LICENSE_BLOB_PATH):
with open(LICENSE_BLOB_PATH, "rb") as f:
LICENSE_BLOB = f.read()
LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
pk = None
if LICENSE_PUBLIC_KEY:
pk = serialization.load_pem_public_key(
f"""
-----BEGIN PUBLIC KEY-----
{LICENSE_PUBLIC_KEY}
-----END PUBLIC KEY-----
""".encode(
"utf-8"
)
)
####################################
# MODELS
####################################
@@ -444,6 +527,38 @@ else:
MODELS_CACHE_TTL = 1
####################################
# CHAT
####################################
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get(
"CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1"
)
if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "":
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
else:
try:
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int(
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE
)
except Exception:
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
)
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
else:
try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
####################################
# WEBSOCKET SUPPORT
####################################
@@ -456,6 +571,9 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
WEBSOCKET_REDIS_CLUSTER = (
os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true"
)
websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
@@ -465,9 +583,9 @@ except ValueError:
WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "":
@@ -609,13 +727,34 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
ENABLE_OTEL_TRACES = os.environ.get("ENABLE_OTEL_TRACES", "False").lower() == "true"
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true"
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
)
OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
)
OTEL_EXPORTER_OTLP_INSECURE = (
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
)
OTEL_METRICS_EXPORTER_OTLP_INSECURE = (
os.environ.get(
"OTEL_METRICS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
).lower()
== "true"
)
OTEL_LOGS_EXPORTER_OTLP_INSECURE = (
os.environ.get(
"OTEL_LOGS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
).lower()
== "true"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
@@ -626,11 +765,30 @@ OTEL_TRACES_SAMPLER = os.environ.get(
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get(
"OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
)
OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get(
"OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
)
OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get(
"OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
)
OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get(
"OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
)
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
).lower() # grpc or http
OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
).lower() # grpc or http
OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
).lower() # grpc or http
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
+13 -5
View File
@@ -47,7 +47,7 @@ from open_webui.utils.misc import (
)
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
apply_system_prompt_to_body,
)
@@ -219,6 +219,15 @@ async def generate_function_chat_completion(
__task__ = metadata.get("task", None)
__task_body__ = metadata.get("task_body", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
@@ -230,9 +239,10 @@ async def generate_function_chat_completion(
"__files__": files,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request,
}
extra_params["__tools__"] = get_tools(
extra_params["__tools__"] = await get_tools(
request,
tool_ids,
user,
@@ -253,9 +263,7 @@ async def generate_function_chat_completion(
if params:
system = params.pop("system", None)
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = apply_model_system_prompt_to_body(
system, form_data, metadata, user
)
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = get_function_module_by_id(request, pipe_id)
+44 -2
View File
@@ -1,3 +1,4 @@
import os
import json
import logging
from contextlib import contextmanager
@@ -13,9 +14,10 @@ from open_webui.env import (
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, types
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool, NullPool
@@ -79,10 +81,50 @@ handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
if "sqlite" in SQLALCHEMY_DATABASE_URL:
# Handle SQLCipher URLs
if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
# Extract database path from SQLCipher URL
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{database_password}'")
return conn
engine = create_engine(
"sqlite://", # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
log.info("Connected to encrypted SQLite database using SQLCipher")
elif "sqlite" in SQLALCHEMY_DATABASE_URL:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
def on_connect(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
if DATABASE_ENABLE_SQLITE_WAL:
cursor.execute("PRAGMA journal_mode=WAL")
else:
cursor.execute("PRAGMA journal_mode=DELETE")
cursor.close()
event.listen(engine, "connect", on_connect)
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
+40 -16
View File
@@ -1,4 +1,5 @@
import logging
import os
from contextvars import ContextVar
from open_webui.env import SRC_LOG_LEVELS
@@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url):
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
# Check if using SQLCipher protocol
if db_url.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to PostgreSQL database")
log.info("Connected to encrypted SQLite database using SQLCipher")
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to SQLite database")
else:
raise ValueError("Unsupported database connection")
# Standard database connection (existing logic)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to PostgreSQL database")
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to SQLite database")
else:
raise ValueError("Unsupported database connection")
return db
+232 -68
View File
@@ -57,6 +57,7 @@ from open_webui.utils.logger import start_logger
from open_webui.socket.main import (
app as socket_app,
periodic_usage_pool_cleanup,
get_event_emitter,
get_models_in_use,
get_active_user_ids,
)
@@ -85,6 +86,7 @@ from open_webui.routers import (
tools,
users,
utils,
scim,
)
from open_webui.routers.retrieval import (
@@ -102,7 +104,6 @@ from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.config import (
LICENSE_KEY,
# Ollama
ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS,
@@ -156,6 +157,7 @@ from open_webui.config import (
IMAGE_SIZE,
IMAGE_STEPS,
IMAGES_OPENAI_API_BASE_URL,
IMAGES_OPENAI_API_VERSION,
IMAGES_OPENAI_API_KEY,
IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY,
@@ -185,6 +187,7 @@ from open_webui.config import (
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS,
WHISPER_MODEL,
WHISPER_VAD_FILTER,
WHISPER_LANGUAGE,
@@ -227,20 +230,27 @@ from open_webui.config import (
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
DATALAB_MARKER_LANGS,
DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_OUTPUT_FORMAT,
DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCLING_DO_OCR,
DOCLING_FORCE_OCR,
DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG,
DOCLING_PDF_BACKEND,
DOCLING_TABLE_MODE,
DOCLING_PIPELINE,
DOCLING_DO_PICTURE_DESCRIPTION,
DOCLING_PICTURE_DESCRIPTION_MODE,
DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -325,6 +335,7 @@ from open_webui.config import (
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
ENABLE_EVALUATION_ARENA_MODELS,
BYPASS_ADMIN_ACCESS_CONTROL,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
PENDING_USER_OVERLAY_CONTENT,
@@ -373,6 +384,7 @@ from open_webui.config import (
RESPONSE_WATERMARK,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_EXPORT,
# Tasks
TASK_MODEL,
@@ -395,10 +407,12 @@ from open_webui.config import (
reset_config,
)
from open_webui.env import (
LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
REDIS_CLUSTER,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
@@ -412,9 +426,13 @@ from open_webui.env import (
WEBUI_SECRET_KEY,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
# SCIM
SCIM_ENABLED,
SCIM_TOKEN,
ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
@@ -455,6 +473,7 @@ from open_webui.utils.redis import get_redis_connection
from open_webui.tasks import (
redis_task_command_listener,
list_task_ids_by_item_id,
create_task,
stop_task,
list_tasks,
) # Import from tasks.py
@@ -462,6 +481,9 @@ from open_webui.tasks import (
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()
@@ -524,6 +546,7 @@ async def lifespan(app: FastAPI):
redis_sentinels=get_sentinels_from_env(
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
),
redis_cluster=REDIS_CLUSTER,
async_mode=True,
)
@@ -574,11 +597,13 @@ app = FastAPI(
)
oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager
app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
redis_cluster=REDIS_CLUSTER,
redis_key_prefix=REDIS_KEY_PREFIX,
)
app.state.redis = None
@@ -642,6 +667,15 @@ app.state.TOOL_SERVERS = []
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
########################################
#
# SCIM
#
########################################
app.state.SCIM_ENABLED = SCIM_ENABLED
app.state.SCIM_TOKEN = SCIM_TOKEN
########################################
#
# MODELS
@@ -767,7 +801,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY
app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS
app.state.config.DATALAB_MARKER_API_BASE_URL = DATALAB_MARKER_API_BASE_URL
app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = DATALAB_MARKER_ADDITIONAL_CONFIG
app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE
app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR
app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE
@@ -775,14 +810,20 @@ app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTI
app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES
app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM
app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT
app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND
app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE
app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
@@ -829,7 +870,10 @@ app.state.config.WEB_SEARCH_ENGINE = WEB_SEARCH_ENGINE
app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = WEB_SEARCH_DOMAIN_FILTER_LIST
app.state.config.WEB_SEARCH_RESULT_COUNT = WEB_SEARCH_RESULT_COUNT
app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
@@ -892,14 +936,19 @@ try:
app.state.config.RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
)
app.state.rf = get_rf(
app.state.config.RAG_RERANKING_ENGINE,
app.state.config.RAG_RERANKING_MODEL,
app.state.config.RAG_EXTERNAL_RERANKER_URL,
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
RAG_RERANKING_MODEL_AUTO_UPDATE,
)
if (
app.state.config.ENABLE_RAG_HYBRID_SEARCH
and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
):
app.state.rf = get_rf(
app.state.config.RAG_RERANKING_ENGINE,
app.state.config.RAG_RERANKING_MODEL,
app.state.config.RAG_EXTERNAL_RERANKER_URL,
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
RAG_RERANKING_MODEL_AUTO_UPDATE,
)
else:
app.state.rf = None
except Exception as e:
log.error(f"Error updating models: {e}")
pass
@@ -982,6 +1031,7 @@ app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
@@ -1211,6 +1261,10 @@ app.include_router(
)
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management
if SCIM_ENABLED:
app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["scim"])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
@@ -1233,6 +1287,7 @@ if audit_level != AuditLevel.NONE:
@app.get("/api/models")
@app.get("/api/v1/models") # Experimental: Compatibility with OpenAI API
async def get_models(
request: Request, refresh: bool = False, user=Depends(get_verified_user)
):
@@ -1252,8 +1307,12 @@ async def get_models(
model_info = Models.get_model_by_id(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
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
)
):
filtered_models.append(model)
@@ -1288,15 +1347,21 @@ async def get_models(
model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)}
# Sort models by order list priority, with fallback for those not in the list
models.sort(
key=lambda x: (model_order_dict.get(x["id"], float("inf")), x["name"])
key=lambda model: (
model_order_dict.get(model.get("id", ""), float("inf")),
(model.get("name", "") or ""),
)
)
# Filter out models that the user does not have access to
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
if (
user.role == "user"
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
) and not BYPASS_MODEL_ACCESS_CONTROL:
models = get_filtered_models(models, user)
log.debug(
f"/api/models returned filtered models accessible to the user: {json.dumps([model['id'] for model in models])}"
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
)
return {"data": models}
@@ -1313,6 +1378,7 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
@app.post("/api/embeddings")
@app.post("/api/v1/embeddings") # Experimental: Compatibility with OpenAI API
async def embeddings(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
@@ -1339,6 +1405,7 @@ async def embeddings(
@app.post("/api/chat/completions")
@app.post("/api/v1/chat/completions") # Experimental: Compatibility with OpenAI API
async def chat_completion(
request: Request,
form_data: dict,
@@ -1347,13 +1414,21 @@ async def chat_completion(
if not request.app.state.MODELS:
await get_all_models(request, user=user)
model_id = form_data.get("model", None)
model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id, request.cookies.get("oauth_session_id", None)
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
metadata = {}
try:
if not model_item.get("direct", False):
model_id = form_data.get("model", None)
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
@@ -1361,7 +1436,9 @@ async def chat_completion(
model_info = Models.get_model_by_id(model_id)
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
if not BYPASS_MODEL_ACCESS_CONTROL and (
user.role != "admin" or not BYPASS_ADMIN_ACCESS_CONTROL
):
try:
check_model_access(user, model)
except Exception as e:
@@ -1373,6 +1450,23 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
model_info_params = (
model_info.params.model_dump() if model_info and model_info.params else {}
)
# Chat Params
stream_delta_chunk_size = form_data.get("params", {}).get(
"stream_delta_chunk_size"
)
reasoning_tags = form_data.get("params", {}).get("reasoning_tags")
# Model Params
if model_info_params.get("stream_delta_chunk_size"):
stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size")
if model_info_params.get("reasoning_tags") is not None:
reasoning_tags = model_info_params.get("reasoning_tags")
metadata = {
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
@@ -1386,64 +1480,111 @@ async def chat_completion(
"variables": form_data.get("variables", {}),
"model": model,
"direct": model_item.get("direct", False),
**(
{"function_calling": "native"}
if form_data.get("params", {}).get("function_calling") == "native"
or (
model_info
and model_info.params.model_dump().get("function_calling")
== "native"
)
else {}
),
"params": {
"stream_delta_chunk_size": stream_delta_chunk_size,
"reasoning_tags": reasoning_tags,
"function_calling": (
"native"
if (
form_data.get("params", {}).get("function_calling") == "native"
or model_info_params.get("function_calling") == "native"
)
else "default"
),
},
}
if metadata.get("chat_id") and (user and user.role != "admin"):
if metadata["chat_id"] != "local":
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
request.state.metadata = metadata
form_data["metadata"] = metadata
form_data, metadata, events = await process_chat_payload(
request, form_data, user, metadata, model
)
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
if metadata.get("chat_id") and metadata.get("message_id"):
# Update the chat message with the error
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
)
log.debug(f"Error processing chat metadata: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
try:
response = await chat_completion_handler(request, form_data, user)
return await process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
)
except Exception as e:
log.debug(f"Error in chat completion: {e}")
if metadata.get("chat_id") and metadata.get("message_id"):
# Update the chat message with the error
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
async def process_chat(request, form_data, user, metadata, model):
try:
form_data, metadata, events = await process_chat_payload(
request, form_data, user, metadata, model
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
response = await chat_completion_handler(request, form_data, user)
if metadata.get("chat_id") and metadata.get("message_id"):
try:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"model": model_id,
},
)
except:
pass
return await process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
)
except asyncio.CancelledError:
log.info("Chat processing was cancelled")
try:
event_emitter = get_event_emitter(metadata)
await event_emitter(
{"type": "chat:tasks:cancel"},
)
except Exception as e:
pass
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
if metadata.get("chat_id") and metadata.get("message_id"):
# Update the chat message with the error
try:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
)
event_emitter = get_event_emitter(metadata)
await event_emitter(
{
"type": "chat:message:error",
"data": {"error": {"content": str(e)}},
}
)
await event_emitter(
{"type": "chat:tasks:cancel"},
)
except:
pass
if (
metadata.get("session_id")
and metadata.get("chat_id")
and metadata.get("message_id")
):
# Asynchronous Chat Processing
task_id, _ = await create_task(
request.app.state.redis,
process_chat(request, form_data, user, metadata, model),
id=metadata["chat_id"],
)
return {"status": True, "task_id": task_id}
else:
return await process_chat(request, form_data, user, metadata, model)
# Alias for chat_completion (Legacy)
@@ -1529,8 +1670,18 @@ async def list_tasks_by_chat_id_endpoint(
@app.get("/api/config")
async def get_app_config(request: Request):
user = None
if "token" in request.cookies:
token = None
auth_header = request.headers.get("Authorization")
if auth_header:
cred = get_http_authorization_cred(auth_header)
if cred:
token = cred.credentials
if not token and "token" in request.cookies:
token = request.cookies.get("token")
if token:
try:
data = decode_token(token)
except Exception as e:
@@ -1563,6 +1714,7 @@ async def get_app_config(request: Request):
"features": {
"auth": WEBUI_AUTH,
"auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER),
"enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
"enable_ldap": app.state.config.ENABLE_LDAP,
"enable_api_key": app.state.config.ENABLE_API_KEY,
"enable_signup": app.state.config.ENABLE_SIGNUP,
@@ -1641,19 +1793,32 @@ async def get_app_config(request: Request):
else {}
),
}
if user is not None
if user is not None and (user.role in ["admin", "user"])
else {
**(
{
"ui": {
"pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE,
"pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
}
}
if user and user.role == "pending"
else {}
),
**(
{
"metadata": {
"login_footer": app.state.LICENSE_METADATA.get(
"login_footer", ""
)
),
"auth_logo_position": app.state.LICENSE_METADATA.get(
"auth_logo_position", ""
),
}
}
if app.state.LICENSE_METADATA
else {}
)
),
}
),
}
@@ -1765,11 +1930,10 @@ async def get_manifest_json():
return {
"name": app.state.WEBUI_NAME,
"short_name": app.state.WEBUI_NAME,
"description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.",
"description": f"{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.",
"start_url": "/",
"display": "standalone",
"background_color": "#343541",
"orientation": "any",
"icons": [
{
"src": "/static/logo.png",
+34 -7
View File
@@ -2,8 +2,8 @@ from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.env import DATABASE_URL
from sqlalchemy import engine_from_config, pool
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -62,11 +62,38 @@ def run_migrations_online() -> None:
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
# Extract database path from SQLCipher URL
db_path = DB_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return conn
connectable = create_engine(
"sqlite://", # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
else:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
@@ -0,0 +1,46 @@
"""Add indexes
Revision ID: 018012973d35
Revises: d31026856c01
Create Date: 2025-08-13 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "018012973d35"
down_revision = "d31026856c01"
branch_labels = None
depends_on = None
def upgrade():
# Chat table indexes
op.create_index("folder_id_idx", "chat", ["folder_id"])
op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
# Tag table index
op.create_index("user_id_idx", "tag", ["user_id"])
# Function table index
op.create_index("is_global_idx", "function", ["is_global"])
def downgrade():
# Chat table indexes
op.drop_index("folder_id_idx", table_name="chat")
op.drop_index("user_id_pinned_idx", table_name="chat")
op.drop_index("user_id_archived_idx", table_name="chat")
op.drop_index("updated_at_user_id_idx", table_name="chat")
op.drop_index("folder_id_user_id_idx", table_name="chat")
# Tag table index
op.drop_index("user_id_idx", table_name="tag")
# Function table index
op.drop_index("is_global_idx", table_name="function")
@@ -0,0 +1,52 @@
"""Add oauth_session table
Revision ID: 38d63c18f30f
Revises: 3af16a1c9fb6
Create Date: 2025-09-08 14:19:59.583921
"""
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"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create oauth_session table
op.create_table(
"oauth_session",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
)
# Create indexes for better performance
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
op.create_index(
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
)
def downgrade() -> None:
# Drop indexes first
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
# Drop the table
op.drop_table("oauth_session")
@@ -0,0 +1,32 @@
"""update user table
Revision ID: 3af16a1c9fb6
Revises: 018012973d35
Create Date: 2025-08-21 02:07:18.078283
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3af16a1c9fb6"
down_revision: Union[str, None] = "018012973d35"
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("username", sa.String(length=50), nullable=True))
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("user", "username")
op.drop_column("user", "bio")
op.drop_column("user", "gender")
op.drop_column("user", "date_of_birth")
-5
View File
@@ -73,11 +73,6 @@ class ProfileImageUrlForm(BaseModel):
profile_image_url: str
class UpdateProfileForm(BaseModel):
profile_image_url: str
name: str
class UpdatePasswordForm(BaseModel):
password: str
new_password: str
+75 -4
View File
@@ -6,10 +6,11 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, Index
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import bindparam
@@ -40,6 +41,20 @@ class Chat(Base):
meta = Column(JSON, server_default="{}")
folder_id = Column(Text, nullable=True)
__table_args__ = (
# Performance indexes for common queries
# WHERE folder_id = ...
Index("folder_id_idx", "folder_id"),
# WHERE user_id = ... AND pinned = ...
Index("user_id_pinned_idx", "user_id", "pinned"),
# WHERE user_id = ... AND archived = ...
Index("user_id_archived_idx", "user_id", "archived"),
# WHERE user_id = ... ORDER BY updated_at DESC
Index("updated_at_user_id_idx", "updated_at", "user_id"),
# WHERE folder_id = ... AND user_id = ...
Index("folder_id_user_id_idx", "folder_id", "user_id"),
)
class ChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -296,6 +311,9 @@ class ChatTable:
"user_id": f"shared-{chat_id}",
"title": chat.title,
"chat": chat.chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
"created_at": chat.created_at,
"updated_at": int(time.time()),
}
@@ -327,7 +345,9 @@ class ChatTable:
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.meta = chat.meta
shared_chat.pinned = chat.pinned
shared_chat.folder_id = chat.folder_id
shared_chat.updated_at = int(time.time())
db.commit()
db.refresh(shared_chat)
@@ -612,8 +632,45 @@ class ChatTable:
if word.startswith("tag:")
]
# Extract folder names - handle spaces and case insensitivity
folders = Folders.search_folders_by_names(
user_id,
[
word.replace("folder:", "")
for word in search_text_words
if word.startswith("folder:")
],
)
folder_ids = [folder.id for folder in folders]
is_pinned = None
if "pinned:true" in search_text_words:
is_pinned = True
elif "pinned:false" in search_text_words:
is_pinned = False
is_archived = None
if "archived:true" in search_text_words:
is_archived = True
elif "archived:false" in search_text_words:
is_archived = False
is_shared = None
if "shared:true" in search_text_words:
is_shared = True
elif "shared:false" in search_text_words:
is_shared = False
search_text_words = [
word for word in search_text_words if not word.startswith("tag:")
word
for word in search_text_words
if (
not word.startswith("tag:")
and not word.startswith("folder:")
and not word.startswith("pinned:")
and not word.startswith("archived:")
and not word.startswith("shared:")
)
]
search_text = " ".join(search_text_words)
@@ -621,9 +678,23 @@ class ChatTable:
with get_db() as db:
query = db.query(Chat).filter(Chat.user_id == user_id)
if not include_archived:
if is_archived is not None:
query = query.filter(Chat.archived == is_archived)
elif not include_archived:
query = query.filter(Chat.archived == False)
if is_pinned is not None:
query = query.filter(Chat.pinned == is_pinned)
if is_shared is not None:
if is_shared:
query = query.filter(Chat.share_id.isnot(None))
else:
query = query.filter(Chat.share_id.is_(None))
if folder_ids:
query = query.filter(Chat.folder_id.in_(folder_ids))
query = query.order_by(Chat.updated_at.desc())
# Check if the database dialect is either 'sqlite' or 'postgresql'
+9
View File
@@ -147,6 +147,15 @@ class FilesTable:
with get_db() as db:
return [FileModel.model_validate(file) for file in db.query(File).all()]
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
file = self.get_file_by_id(id)
if not file:
return False
if file.user_id == user_id:
return True
# Implement additional access control logic here as needed
return False
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db:
return [
+80 -21
View File
@@ -2,14 +2,14 @@ import logging
import time
import uuid
from typing import Optional
import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from open_webui.internal.db import Base, get_db
from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
@@ -58,6 +58,14 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel):
name: str
data: Optional[dict] = None
meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
class FolderUpdateForm(BaseModel):
name: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
@@ -106,7 +114,7 @@ class FolderTable:
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[FolderModel]:
) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
folders = []
@@ -191,7 +199,7 @@ class FolderTable:
return
def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderForm
self, id: str, user_id: str, form_data: FolderUpdateForm
) -> Optional[FolderModel]:
try:
with get_db() as db:
@@ -222,8 +230,13 @@ class FolderTable:
**form_data["data"],
}
folder.updated_at = int(time.time())
if "meta" in form_data:
folder.meta = {
**(folder.meta or {}),
**form_data["meta"],
}
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
@@ -251,18 +264,15 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
try:
folder_ids = []
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return False
return folder_ids
if delete_chats:
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
folder_ids.append(folder.id)
# Delete all children folders
def delete_children(folder):
@@ -270,12 +280,9 @@ class FolderTable:
folder.id, user_id
)
for folder_child in folder_children:
if delete_chats:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
delete_children(folder_child)
folder_ids.append(folder_child.id)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
db.delete(folder)
@@ -284,10 +291,62 @@ class FolderTable:
delete_children(folder)
db.delete(folder)
db.commit()
return True
return folder_ids
except Exception as e:
log.error(f"delete_folder: {e}")
return False
return []
def normalize_folder_name(self, name: str) -> str:
# Replace _ and space with a single space, lower case, collapse multiple spaces
name = re.sub(r"[\s_]+", " ", name)
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str]
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
"""
normalized_queries = [self.normalize_folder_name(q) for q in queries]
if not normalized_queries:
return []
results = {}
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
results[folder.id] = FolderModel.model_validate(folder)
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id
)
for child in children:
results[child.id] = child
# Return the results as a list
if not results:
return []
else:
results = list(results.values())
return results
def search_folders_by_name_contains(
self, user_id: str, query: str
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)
if normalized_query in norm_name:
results.append(FolderModel.model_validate(folder))
return results
Folders = FolderTable()
+34 -11
View File
@@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -31,6 +31,8 @@ class Function(Base):
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
__table_args__ = (Index("is_global_idx", "is_global"),)
class FunctionMeta(BaseModel):
description: Optional[str] = None
@@ -52,6 +54,22 @@ class FunctionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FunctionWithValvesModel(BaseModel):
id: str
user_id: str
name: str
type: str
content: str
meta: FunctionMeta
valves: Optional[dict] = None
is_active: bool = False
is_global: bool = False
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
@@ -109,8 +127,8 @@ class FunctionsTable:
return None
def sync_functions(
self, user_id: str, functions: list[FunctionModel]
) -> list[FunctionModel]:
self, user_id: str, functions: list[FunctionWithValvesModel]
) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
@@ -164,17 +182,24 @@ class FunctionsTable:
except Exception:
return None
def get_functions(self, active_only=False) -> list[FunctionModel]:
def get_functions(
self, active_only=False, include_valves=False
) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db:
if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
else:
functions = db.query(Function).all()
if include_valves:
return [
FunctionModel.model_validate(function)
for function in db.query(Function).filter_by(is_active=True).all()
FunctionWithValvesModel.model_validate(function)
for function in functions
]
else:
return [
FunctionModel.model_validate(function)
for function in db.query(Function).all()
FunctionModel.model_validate(function) for function in functions
]
def get_functions_by_type(
@@ -250,9 +275,7 @@ class FunctionsTable:
return user_settings["functions"]["valves"].get(id, {})
except Exception as e:
log.exception(
f"Error getting user values by id {id} and user id {user_id}: {e}"
)
log.exception(f"Error getting user values by id {id} and user id {user_id}")
return None
def update_user_valves_by_id_and_user_id(
+18 -8
View File
@@ -288,13 +288,17 @@ class GroupTable:
if not group:
return None
if not group.user_ids:
group.user_ids = []
group_user_ids = group.user_ids
if not group_user_ids or not isinstance(group_user_ids, list):
group_user_ids = []
group_user_ids = list(set(group_user_ids)) # Deduplicate
for user_id in user_ids:
if user_id not in group.user_ids:
group.user_ids.append(user_id)
if user_id not in group_user_ids:
group_user_ids.append(user_id)
group.user_ids = group_user_ids
group.updated_at = int(time.time())
db.commit()
db.refresh(group)
@@ -312,14 +316,20 @@ class GroupTable:
if not group:
return None
if not group.user_ids:
group_user_ids = group.user_ids
if not group_user_ids or not isinstance(group_user_ids, list):
return GroupModel.model_validate(group)
for user_id in user_ids:
if user_id in group.user_ids:
group.user_ids.remove(user_id)
group_user_ids = list(set(group_user_ids)) # Deduplicate
for user_id in user_ids:
if user_id in group_user_ids:
group_user_ids.remove(user_id)
group.user_ids = group_user_ids
group.updated_at = int(time.time())
db.commit()
db.refresh(group)
return GroupModel.model_validate(group)
+25 -5
View File
@@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import FileMetadataResponse
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@@ -128,11 +129,18 @@ class KnowledgeTable:
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
with get_db() as db:
knowledge_bases = []
for knowledge in (
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
):
user = Users.get_user_by_id(knowledge.user_id)
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
knowledge_bases = []
for knowledge in all_knowledge:
user = users_dict.get(knowledge.user_id)
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
@@ -143,15 +151,27 @@ class KnowledgeTable:
)
return knowledge_bases
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
if not knowledge:
return False
if knowledge.user_id == user_id:
return True
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
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)
or has_access(
user_id, permission, knowledge_base.access_control, user_group_ids
)
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
+13 -4
View File
@@ -71,9 +71,13 @@ class MemoriesTable:
) -> Optional[MemoryModel]:
with get_db() as db:
try:
db.query(Memory).filter_by(id=id, user_id=user_id).update(
{"content": content, "updated_at": int(time.time())}
)
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
memory.content = content
memory.updated_at = int(time.time())
db.commit()
return self.get_memory_by_id(id)
except Exception:
@@ -127,7 +131,12 @@ class MemoriesTable:
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
try:
db.query(Memory).filter_by(id=id, user_id=user_id).delete()
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
# Delete the memory
db.delete(memory)
db.commit()
return True
+56 -3
View File
@@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@@ -175,9 +176,16 @@ class ModelsTable:
def get_models(self) -> list[ModelUserResponse]:
with get_db() as db:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
models = []
for model in db.query(Model).filter(Model.base_model_id != None).all():
user = Users.get_user_by_id(model.user_id)
for model in all_models:
user = users_dict.get(model.user_id)
models.append(
ModelUserResponse.model_validate(
{
@@ -199,11 +207,12 @@ class ModelsTable:
self, user_id: str, permission: str = "write"
) -> list[ModelUserResponse]:
models = self.get_models()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control)
or has_access(user_id, permission, model.access_control, user_group_ids)
]
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
@@ -269,5 +278,49 @@ class ModelsTable:
except Exception:
return False
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
try:
with get_db() as db:
# Get existing models
existing_models = db.query(Model).all()
existing_ids = {model.id for model in existing_models}
# Prepare a set of new model IDs
new_model_ids = {model.id for model in models}
# Update or insert models
for model in models:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
else:
new_model = Model(
**{
**model.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_model)
# Remove models that are no longer present
for model in existing_models:
if model.id not in new_model_ids:
db.delete(model)
db.commit()
return [
ModelModel.model_validate(model) for model in db.query(Model).all()
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
return []
Models = ModelsTable()
+3 -1
View File
@@ -4,6 +4,7 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
@@ -105,11 +106,12 @@ class NoteTable:
self, user_id: str, permission: str = "write"
) -> list[NoteModel]:
notes = self.get_notes()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
note
for note in notes
if note.user_id == user_id
or has_access(user_id, permission, note.access_control)
or has_access(user_id, permission, note.access_control, user_group_ids)
]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
+246
View File
@@ -0,0 +1,246 @@
import time
import logging
import uuid
from typing import Optional, List
import base64
import hashlib
import json
from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# DB MODEL
####################
class OAuthSession(Base):
__tablename__ = "oauth_session"
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False)
token = Column(
Text, nullable=False
) # JSON with access_token, id_token, refresh_token
expires_at = Column(BigInteger, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
# Add indexes for better performance
__table_args__ = (
Index("idx_oauth_session_user_id", "user_id"),
Index("idx_oauth_session_expires_at", "expires_at"),
Index("idx_oauth_session_user_provider", "user_id", "provider"),
)
class OAuthSessionModel(BaseModel):
id: str
user_id: str
provider: str
token: dict
expires_at: int # timestamp in epoch
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class OAuthSessionResponse(BaseModel):
id: str
user_id: str
provider: str
expires_at: int
class OAuthSessionTable:
def __init__(self):
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
if not self.encryption_key:
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
if len(self.encryption_key) != 44:
key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
self.encryption_key = base64.urlsafe_b64encode(key_bytes)
else:
self.encryption_key = self.encryption_key.encode()
try:
self.fernet = Fernet(self.encryption_key)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
raise
def _encrypt_token(self, token) -> str:
"""Encrypt OAuth tokens for storage"""
try:
token_json = json.dumps(token)
encrypted = self.fernet.encrypt(token_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting tokens: {e}")
raise
def _decrypt_token(self, token: str):
"""Decrypt OAuth tokens from storage"""
try:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {e}")
raise
def create_session(
self,
user_id: str,
provider: str,
token: dict,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db() as db:
current_time = int(time.time())
id = str(uuid.uuid4())
result = OAuthSession(
**{
"id": id,
"user_id": user_id,
"provider": provider,
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"created_at": current_time,
"updated_at": current_time,
}
)
db.add(result)
db.commit()
db.refresh(result)
if result:
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
else:
return None
except Exception as e:
log.error(f"Error creating OAuth session: {e}")
return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db() as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db() as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
.first()
)
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db() as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = []
for session in sessions:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
return results
except Exception as e:
log.error(f"Error getting OAuth sessions by user ID: {e}")
return []
def update_session_by_id(
self, session_id: str, token: dict
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db() as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
{
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"updated_at": current_time,
}
)
db.commit()
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error updating OAuth session tokens: {e}")
return None
def delete_session_by_id(self, session_id: str) -> bool:
"""Delete an OAuth session"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
except Exception as e:
log.error(f"Error deleting OAuth session: {e}")
return False
def delete_sessions_by_user_id(self, user_id: str) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
return True
except Exception as e:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False
OAuthSessions = OAuthSessionTable()
+12 -4
View File
@@ -2,6 +2,7 @@ import time
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
@@ -103,10 +104,16 @@ class PromptsTable:
def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db:
prompts = []
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
user = Users.get_user_by_id(prompt.user_id)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
prompts = []
for prompt in all_prompts:
user = users_dict.get(prompt.user_id)
prompts.append(
PromptUserResponse.model_validate(
{
@@ -122,12 +129,13 @@ class PromptsTable:
self, user_id: str, permission: str = "write"
) -> list[PromptUserResponse]:
prompts = self.get_prompts()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control)
or has_access(user_id, permission, prompt.access_control, user_group_ids)
]
def update_prompt_by_command(
+6 -1
View File
@@ -8,7 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -24,6 +24,11 @@ class Tag(Base):
user_id = Column(String)
meta = Column(JSON, nullable=True)
__table_args__ = (
PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),
Index("user_id_idx", "user_id"),
)
# Unique constraint ensuring (id, user_id) is unique, not just the `id` column
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
+14 -4
View File
@@ -4,6 +4,8 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -144,9 +146,16 @@ class ToolsTable:
def get_tools(self) -> list[ToolUserModel]:
with get_db() as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
user_ids = list(set(tool.user_id for tool in all_tools))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
tools = []
for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all():
user = Users.get_user_by_id(tool.user_id)
for tool in all_tools:
user = users_dict.get(tool.user_id)
tools.append(
ToolUserModel.model_validate(
{
@@ -161,12 +170,13 @@ class ToolsTable:
self, user_id: str, permission: str = "write"
) -> list[ToolUserModel]:
tools = self.get_tools()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control)
or has_access(user_id, permission, tool.access_control, user_group_ids)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
@@ -175,7 +185,7 @@ class ToolsTable:
tool = db.get(Tool, id)
return tool.valves if tool.valves else {}
except Exception as e:
log.exception(f"Error getting tool valves by id {id}: {e}")
log.exception(f"Error getting tool valves by id {id}")
return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
+46 -14
View File
@@ -4,14 +4,17 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.utils.misc import throttle
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
from sqlalchemy import BigInteger, Column, String, Text, Date
from sqlalchemy import or_
import datetime
####################
# User DB Schema
@@ -23,20 +26,28 @@ class User(Base):
id = Column(String, primary_key=True)
name = Column(String)
email = Column(String)
username = Column(String(50), nullable=True)
role = Column(String)
profile_image_url = Column(Text)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
bio = Column(Text, nullable=True)
gender = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
info = Column(JSONField, nullable=True)
settings = Column(JSONField, nullable=True)
api_key = Column(String, nullable=True, unique=True)
settings = Column(JSONField, nullable=True)
info = Column(JSONField, nullable=True)
oauth_sub = Column(Text, unique=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class UserSettings(BaseModel):
ui: Optional[dict] = {}
@@ -47,20 +58,27 @@ class UserSettings(BaseModel):
class UserModel(BaseModel):
id: str
name: str
email: str
username: Optional[str] = None
role: str = "pending"
profile_image_url: str
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
info: Optional[dict] = None
settings: Optional[UserSettings] = None
api_key: Optional[str] = None
oauth_sub: Optional[str] = None
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
api_key: Optional[str] = None
settings: Optional[UserSettings] = None
info: Optional[dict] = None
oauth_sub: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@@ -69,6 +87,14 @@ class UserModel(BaseModel):
####################
class UpdateProfileForm(BaseModel):
profile_image_url: str
name: str
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
class UserListResponse(BaseModel):
users: list[UserModel]
total: int
@@ -258,6 +284,10 @@ class UsersTable:
with get_db() as db:
return db.query(User).count()
def has_users(self) -> bool:
with get_db() as db:
return db.query(db.query(User).exists()).scalar()
def get_first_user(self) -> UserModel:
try:
with get_db() as db:
@@ -307,6 +337,7 @@ class UsersTable:
except Exception:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
try:
with get_db() as db:
@@ -342,7 +373,8 @@ class UsersTable:
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
# return UserModel(**user.dict())
except Exception:
except Exception as e:
print(e)
return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
@@ -15,24 +15,28 @@ class DatalabMarkerLoader:
self,
file_path: str,
api_key: str,
langs: Optional[str] = None,
api_base_url: str,
additional_config: Optional[str] = None,
use_llm: bool = False,
skip_cache: bool = False,
force_ocr: bool = False,
paginate: bool = False,
strip_existing_ocr: bool = False,
disable_image_extraction: bool = False,
format_lines: bool = False,
output_format: str = None,
):
self.file_path = file_path
self.api_key = api_key
self.langs = langs
self.api_base_url = api_base_url
self.additional_config = additional_config
self.use_llm = use_llm
self.skip_cache = skip_cache
self.force_ocr = force_ocr
self.paginate = paginate
self.strip_existing_ocr = strip_existing_ocr
self.disable_image_extraction = disable_image_extraction
self.format_lines = format_lines
self.output_format = output_format
def _get_mime_type(self, filename: str) -> str:
@@ -60,7 +64,7 @@ class DatalabMarkerLoader:
return mime_map.get(ext, "application/octet-stream")
def check_marker_request_status(self, request_id: str) -> dict:
url = f"https://www.datalab.to/api/v1/marker/{request_id}"
url = f"{self.api_base_url}/{request_id}"
headers = {"X-Api-Key": self.api_key}
try:
response = requests.get(url, headers=headers)
@@ -81,22 +85,24 @@ class DatalabMarkerLoader:
)
def load(self) -> List[Document]:
url = "https://www.datalab.to/api/v1/marker"
filename = os.path.basename(self.file_path)
mime_type = self._get_mime_type(filename)
headers = {"X-Api-Key": self.api_key}
form_data = {
"langs": self.langs,
"use_llm": str(self.use_llm).lower(),
"skip_cache": str(self.skip_cache).lower(),
"force_ocr": str(self.force_ocr).lower(),
"paginate": str(self.paginate).lower(),
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
"disable_image_extraction": str(self.disable_image_extraction).lower(),
"format_lines": str(self.format_lines).lower(),
"output_format": self.output_format,
}
if self.additional_config and self.additional_config.strip():
form_data["additional_config"] = self.additional_config
log.info(
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
)
@@ -105,7 +111,10 @@ class DatalabMarkerLoader:
with open(self.file_path, "rb") as f:
files = {"file": (filename, f, mime_type)}
response = requests.post(
url, data=form_data, files=files, headers=headers
f"{self.api_base_url}",
data=form_data,
files=files,
headers=headers,
)
response.raise_for_status()
result = response.json()
@@ -133,74 +142,92 @@ class DatalabMarkerLoader:
check_url = result.get("request_check_url")
request_id = result.get("request_id")
if not check_url:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned."
)
for _ in range(300): # Up to 10 minutes
time.sleep(2)
try:
poll_response = requests.get(check_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
except (requests.HTTPError, ValueError) as e:
raw_body = poll_response.text
log.error(f"Polling error: {e}, response body: {raw_body}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
)
status_val = poll_result.get("status")
success_val = poll_result.get("success")
if status_val == "complete":
summary = {
k: poll_result.get(k)
for k in (
"status",
"output_format",
"success",
"error",
"page_count",
"total_cost",
# Check if this is a direct response (self-hosted) or polling response (DataLab)
if check_url:
# DataLab polling pattern
for _ in range(300): # Up to 10 minutes
time.sleep(2)
try:
poll_response = requests.get(check_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
except (requests.HTTPError, ValueError) as e:
raw_body = poll_response.text
log.error(f"Polling error: {e}, response body: {raw_body}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
)
}
log.info(
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
)
break
if status_val == "failed" or success_val is False:
log.error(
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
)
error_msg = (
poll_result.get("error")
or "Marker returned failure without error message"
status_val = poll_result.get("status")
success_val = poll_result.get("success")
if status_val == "complete":
summary = {
k: poll_result.get(k)
for k in (
"status",
"output_format",
"success",
"error",
"page_count",
"total_cost",
)
}
log.info(
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
)
break
if status_val == "failed" or success_val is False:
log.error(
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
)
error_msg = (
poll_result.get("error")
or "Marker returned failure without error message"
)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Marker processing failed: {error_msg}",
)
else:
raise HTTPException(
status.HTTP_504_GATEWAY_TIMEOUT,
detail="Marker processing timed out",
)
if not poll_result.get("success", False):
error_msg = poll_result.get("error") or "Unknown processing error"
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Marker processing failed: {error_msg}",
detail=f"Final processing failed: {error_msg}",
)
# DataLab format - content in format-specific fields
content_key = self.output_format.lower()
raw_content = poll_result.get(content_key)
final_result = poll_result
else:
raise HTTPException(
status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
)
# Self-hosted direct response - content in "output" field
if "output" in result:
log.info("Self-hosted Marker returned direct response without polling")
raw_content = result.get("output")
final_result = result
else:
available_fields = (
list(result.keys())
if isinstance(result, dict)
else "non-dict response"
)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.",
)
if not poll_result.get("success", False):
error_msg = poll_result.get("error") or "Unknown processing error"
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Final processing failed: {error_msg}",
)
content_key = self.output_format.lower()
raw_content = poll_result.get(content_key)
if content_key == "json":
if self.output_format.lower() == "json":
full_text = json.dumps(raw_content, indent=2)
elif content_key in {"markdown", "html"}:
elif self.output_format.lower() in {"markdown", "html"}:
full_text = str(raw_content).strip()
else:
raise HTTPException(
@@ -211,14 +238,14 @@ class DatalabMarkerLoader:
if not full_text:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Datalab Marker returned empty content",
detail="Marker returned empty content",
)
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
os.makedirs(marker_output_dir, exist_ok=True)
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
file_ext = file_ext_map.get(content_key, "txt")
file_ext = file_ext_map.get(self.output_format.lower(), "txt")
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
output_path = os.path.join(marker_output_dir, output_filename)
@@ -231,13 +258,13 @@ class DatalabMarkerLoader:
metadata = {
"source": filename,
"output_format": poll_result.get("output_format", self.output_format),
"page_count": poll_result.get("page_count", 0),
"output_format": final_result.get("output_format", self.output_format),
"page_count": final_result.get("page_count", 0),
"processed_with_llm": self.use_llm,
"request_id": request_id or "",
}
images = poll_result.get("images", {})
images = final_result.get("images", {})
if images:
metadata["image_count"] = len(images)
metadata["images"] = json.dumps(list(images.keys()))
@@ -1,6 +1,7 @@
import requests
import logging, os
from typing import Iterator, List, Union
from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
@@ -37,7 +38,7 @@ class ExternalDocumentLoader(BaseLoader):
headers["Authorization"] = f"Bearer {self.api_key}"
try:
headers["X-Filename"] = os.path.basename(self.file_path)
headers["X-Filename"] = quote(os.path.basename(self.file_path))
except:
pass
+40 -10
View File
@@ -4,6 +4,7 @@ import ftfy
import sys
import json
from azure.identity import DefaultAzureCredential
from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
BSHTMLLoader,
@@ -147,7 +148,7 @@ class DoclingLoader:
)
}
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
params = {"image_export_mode": "placeholder"}
if self.params:
if self.params.get("do_picture_description"):
@@ -173,7 +174,15 @@ class DoclingLoader:
self.params.get("picture_description_api", {})
)
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
params["do_ocr"] = self.params.get("do_ocr")
params["force_ocr"] = self.params.get("force_ocr")
if (
self.params.get("do_ocr")
and self.params.get("ocr_engine")
and self.params.get("ocr_lang")
):
params["ocr_engine"] = self.params.get("ocr_engine")
params["ocr_lang"] = [
lang.strip()
@@ -181,7 +190,16 @@ class DoclingLoader:
if lang.strip()
]
endpoint = f"{self.url}/v1alpha/convert/file"
if self.params.get("pdf_backend"):
params["pdf_backend"] = self.params.get("pdf_backend")
if self.params.get("table_mode"):
params["table_mode"] = self.params.get("table_mode")
if self.params.get("pipeline"):
params["pipeline"] = self.params.get("pipeline")
endpoint = f"{self.url}/v1/convert/file"
r = requests.post(endpoint, files=files, data=params)
if r.ok:
@@ -281,10 +299,15 @@ class Loader:
"tiff",
]
):
api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
if not api_base_url or api_base_url.strip() == "":
api_base_url = "https://www.datalab.to/api/v1/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349
loader = DatalabMarkerLoader(
file_path=file_path,
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
api_base_url=api_base_url,
additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"),
use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False),
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
@@ -295,6 +318,7 @@ class Loader:
disable_image_extraction=self.kwargs.get(
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
),
format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False),
output_format=self.kwargs.get(
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
),
@@ -321,7 +345,6 @@ class Loader:
elif (
self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != ""
and (
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
or file_content_type
@@ -334,11 +357,18 @@ class Loader:
]
)
):
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "":
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
else:
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
azure_credential=DefaultAzureCredential(),
)
elif (
self.engine == "mistral_ocr"
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
@@ -98,10 +98,9 @@ class YoutubeLoader:
else:
youtube_proxies = None
transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
try:
transcript_list = YouTubeTranscriptApi.list_transcripts(
self.video_id, proxies=youtube_proxies
)
transcript_list = transcript_api.list(self.video_id)
except Exception as e:
log.exception("Loading YouTube transcript failed")
return []
+44 -24
View File
@@ -124,7 +124,12 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float,
) -> dict:
try:
if not collection_result.documents[0]:
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []}
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
bm25_retriever = BM25Retriever.from_texts(
texts=collection_result.documents[0],
metadatas=collection_result.metadatas[0],
@@ -483,17 +488,18 @@ def get_sources_from_items(
if item.get("type") == "text":
# Raw Text
# Used during temporary chat file uploads
# Used during temporary chat file uploads or web page & youtube attachements
if item.get("file"):
if item.get("collection_name"):
# If item has a collection name, use it
collection_names.append(item.get("collection_name"))
elif item.get("file"):
# if item has file data, use it
query_result = {
"documents": [
[item.get("file", {}).get("data", {}).get("content")]
],
"metadatas": [
[item.get("file", {}).get("data", {}).get("meta", {})]
],
"metadatas": [[item.get("file", {}).get("meta", {})]],
}
else:
# Fallback to item content
@@ -508,7 +514,11 @@ def get_sources_from_items(
# Note Attached
note = Notes.get_note_by_id(item.get("id"))
if user.role == "admin" or has_access(user.id, "read", note.access_control):
if note and (
user.role == "admin"
or note.user_id == user.id
or has_access(user.id, "read", note.access_control)
):
# User has access to the note
query_result = {
"documents": [[note.data.get("content", {}).get("md", "")]],
@@ -942,6 +952,7 @@ class RerankCompressor(BaseDocumentCompressor):
) -> Sequence[Document]:
reranking = self.reranking_function is not None
scores = None
if reranking:
scores = self.reranking_function(
[(query, doc.page_content) for doc in documents]
@@ -955,22 +966,31 @@ class RerankCompressor(BaseDocumentCompressor):
)
scores = util.cos_sim(query_embedding, document_embedding)[0]
docs_with_scores = list(
zip(documents, scores.tolist() if not isinstance(scores, list) else scores)
)
if self.r_score:
docs_with_scores = [
(d, s) for d, s in docs_with_scores if s >= self.r_score
]
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
final_results = []
for doc, doc_score in result[: self.top_n]:
metadata = doc.metadata
metadata["score"] = doc_score
doc = Document(
page_content=doc.page_content,
metadata=metadata,
if scores is not None:
docs_with_scores = list(
zip(
documents,
scores.tolist() if not isinstance(scores, list) else scores,
)
)
final_results.append(doc)
return final_results
if self.r_score:
docs_with_scores = [
(d, s) for d, s in docs_with_scores if s >= self.r_score
]
result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
final_results = []
for doc, doc_score in result[: self.top_n]:
metadata = doc.metadata
metadata["score"] = doc_score
doc = Document(
page_content=doc.page_content,
metadata=metadata,
)
final_results.append(doc)
return final_results
else:
log.warning(
"No valid scores found, check your reranking function. Returning original documents."
)
return documents
@@ -11,6 +11,8 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.config import (
CHROMA_DATA_PATH,
CHROMA_HTTP_HOST,
@@ -144,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [item["metadata"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@@ -164,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [item["metadata"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
@@ -2,6 +2,8 @@ from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -243,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
}
for item in batch
@@ -264,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -1,8 +1,12 @@
from pymilvus import MilvusClient as Client
from pymilvus import FieldSchema, DataType
from pymilvus import connections, Collection
import json
import logging
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -186,6 +190,8 @@ class MilvusClient(VectorDBBase):
return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying
collection_name = collection_name.replace("-", "_")
if not self.has_collection(collection_name):
@@ -199,72 +205,36 @@ class MilvusClient(VectorDBBase):
for key, value in filter.items()
]
)
max_limit = 16383 # The maximum number of records per request
all_results = []
if limit is None:
# Milvus default limit for query if not specified is 16384, but docs mention iteration.
# Let's set a practical high number if "all" is intended, or handle true pagination.
# For now, if limit is None, we'll fetch in batches up to a very large number.
# This part could be refined based on expected use cases for "get all".
# For this function signature, None implies "as many as possible" up to Milvus limits.
limit = (
16384 * 10
) # A large number to signify fetching many, will be capped by actual data or max_limit per call.
log.info(
f"Limit not specified for query, fetching up to {limit} results in batches."
)
# Initialize offset and remaining to handle pagination
offset = 0
remaining = limit
collection = Collection(f"{self.collection_prefix}_{collection_name}")
collection.load()
all_results = []
try:
log.info(
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
)
# Loop until there are no more items to fetch or the desired limit is reached
while remaining > 0:
current_fetch = min(
max_limit, remaining if isinstance(remaining, int) else max_limit
)
log.debug(
f"Querying with offset: {offset}, current_fetch: {current_fetch}"
)
results = self.client.query(
collection_name=f"{self.collection_prefix}_{collection_name}",
filter=filter_string,
output_fields=[
"id",
"data",
"metadata",
], # Explicitly list needed fields. Vector not usually needed in query.
limit=current_fetch,
offset=offset,
)
iterator = collection.query_iterator(
filter=filter_string,
output_fields=[
"id",
"data",
"metadata",
],
limit=limit, # Pass the limit directly; None means no limit.
)
if not results:
log.debug("No more results from query.")
break
all_results.extend(results)
results_count = len(results)
log.debug(f"Fetched {results_count} results in this batch.")
if isinstance(remaining, int):
remaining -= results_count
offset += results_count
# Break the loop if the results returned are less than the requested fetch count (means end of data)
if results_count < current_fetch:
log.debug(
"Fetched less than requested, assuming end of results for this query."
)
while True:
result = iterator.next()
if not result:
iterator.close()
break
all_results += result
log.info(f"Total results from query: {len(all_results)}")
return self._result_to_get_result([all_results])
except Exception as e:
log.exception(
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
@@ -311,7 +281,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
}
for item in items
],
@@ -347,7 +317,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
}
for item in items
],
@@ -2,6 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -200,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
}
for item in batch
@@ -222,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -0,0 +1,943 @@
"""
Oracle 23ai Vector Database Client - Fixed Version
# .env
VECTOR_DB = "oracle23ai"
## DBCS or oracle 23ai free
ORACLE_DB_USE_WALLET = false
ORACLE_DB_USER = "DEMOUSER"
ORACLE_DB_PASSWORD = "Welcome123456"
ORACLE_DB_DSN = "localhost:1521/FREEPDB1"
## ADW or ATP
# ORACLE_DB_USE_WALLET = true
# ORACLE_DB_USER = "DEMOUSER"
# ORACLE_DB_PASSWORD = "Welcome123456"
# ORACLE_DB_DSN = "medium"
# ORACLE_DB_DSN = "(description= (retry_count=3)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=xx.oraclecloud.com))(connect_data=(service_name=yy.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
# ORACLE_WALLET_DIR = "/home/opc/adb_wallet"
# ORACLE_WALLET_PASSWORD = "Welcome1"
ORACLE_VECTOR_LENGTH = 768
ORACLE_DB_POOL_MIN = 2
ORACLE_DB_POOL_MAX = 10
ORACLE_DB_POOL_INCREMENT = 1
"""
from typing import Optional, List, Dict, Any, Union
from decimal import Decimal
import logging
import os
import threading
import time
import json
import array
import oracledb
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
ORACLE_DB_USE_WALLET,
ORACLE_DB_USER,
ORACLE_DB_PASSWORD,
ORACLE_DB_DSN,
ORACLE_WALLET_DIR,
ORACLE_WALLET_PASSWORD,
ORACLE_VECTOR_LENGTH,
ORACLE_DB_POOL_MIN,
ORACLE_DB_POOL_MAX,
ORACLE_DB_POOL_INCREMENT,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class Oracle23aiClient(VectorDBBase):
"""
Oracle Vector Database Client for vector similarity search using Oracle Database 23ai.
This client provides an interface to store, retrieve, and search vector embeddings
in an Oracle database. It uses connection pooling for efficient database access
and supports vector similarity search operations.
Attributes:
pool: Connection pool for Oracle database connections
"""
def __init__(self) -> None:
"""
Initialize the Oracle23aiClient with a connection pool.
Creates a connection pool with configurable min/max connections, initializes
the database schema if needed, and sets up necessary tables and indexes.
Raises:
ValueError: If required configuration parameters are missing
Exception: If database initialization fails
"""
self.pool = None
try:
# Create the appropriate connection pool based on DB type
if ORACLE_DB_USE_WALLET:
self._create_adb_pool()
else: # DBCS
self._create_dbcs_pool()
dsn = ORACLE_DB_DSN
log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]")
with self.get_connection() as connection:
log.info(f"Connection version: {connection.version}")
self._initialize_database(connection)
log.info("Oracle Vector Search initialization complete.")
except Exception as e:
log.exception(f"Error during Oracle Vector Search initialization: {e}")
raise
def _create_adb_pool(self) -> None:
"""
Create connection pool for Oracle Autonomous Database.
Uses wallet-based authentication.
"""
self.pool = oracledb.create_pool(
user=ORACLE_DB_USER,
password=ORACLE_DB_PASSWORD,
dsn=ORACLE_DB_DSN,
min=ORACLE_DB_POOL_MIN,
max=ORACLE_DB_POOL_MAX,
increment=ORACLE_DB_POOL_INCREMENT,
config_dir=ORACLE_WALLET_DIR,
wallet_location=ORACLE_WALLET_DIR,
wallet_password=ORACLE_WALLET_PASSWORD,
)
log.info("Created ADB connection pool with wallet authentication.")
def _create_dbcs_pool(self) -> None:
"""
Create connection pool for Oracle Database Cloud Service.
Uses basic authentication without wallet.
"""
self.pool = oracledb.create_pool(
user=ORACLE_DB_USER,
password=ORACLE_DB_PASSWORD,
dsn=ORACLE_DB_DSN,
min=ORACLE_DB_POOL_MIN,
max=ORACLE_DB_POOL_MAX,
increment=ORACLE_DB_POOL_INCREMENT,
)
log.info("Created DB connection pool with basic authentication.")
def get_connection(self):
"""
Acquire a connection from the connection pool with retry logic.
Returns:
connection: A database connection with output type handler configured
"""
max_retries = 3
for attempt in range(max_retries):
try:
connection = self.pool.acquire()
connection.outputtypehandler = self._output_type_handler
return connection
except oracledb.DatabaseError as e:
(error_obj,) = e.args
log.exception(
f"Connection attempt {attempt + 1} failed: {error_obj.message}"
)
if attempt < max_retries - 1:
wait_time = 2**attempt
log.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
else:
raise
def start_health_monitor(self, interval_seconds: int = 60):
"""
Start a background thread to periodically check the health of the connection pool.
Args:
interval_seconds (int): Number of seconds between health checks
"""
def _monitor():
while True:
try:
log.info("[HealthCheck] Running periodic DB health check...")
self.ensure_connection()
log.info("[HealthCheck] Connection is healthy.")
except Exception as e:
log.exception(f"[HealthCheck] Connection health check failed: {e}")
time.sleep(interval_seconds)
thread = threading.Thread(target=_monitor, daemon=True)
thread.start()
log.info(f"Started DB health monitor every {interval_seconds} seconds.")
def _reconnect_pool(self):
"""
Attempt to reinitialize the connection pool if it's been closed or broken.
"""
try:
log.info("Attempting to reinitialize the Oracle connection pool...")
# Close existing pool if it exists
if self.pool:
try:
self.pool.close()
except Exception as close_error:
log.warning(f"Error closing existing pool: {close_error}")
# Re-create the appropriate connection pool based on DB type
if ORACLE_DB_USE_WALLET:
self._create_adb_pool()
else: # DBCS
self._create_dbcs_pool()
log.info("Connection pool reinitialized.")
except Exception as e:
log.exception(f"Failed to reinitialize the connection pool: {e}")
raise
def ensure_connection(self):
"""
Ensure the database connection is alive, reconnecting pool if needed.
"""
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM dual")
except Exception as e:
log.exception(
f"Connection check failed: {e}, attempting to reconnect pool..."
)
self._reconnect_pool()
def _output_type_handler(self, cursor, metadata):
"""
Handle Oracle vector type conversion.
Args:
cursor: Oracle database cursor
metadata: Metadata for the column
Returns:
A variable with appropriate conversion for vector types
"""
if metadata.type_code is oracledb.DB_TYPE_VECTOR:
return cursor.var(
metadata.type_code, arraysize=cursor.arraysize, outconverter=list
)
def _initialize_database(self, connection) -> None:
"""
Initialize database schema, tables and indexes.
Creates the document_chunk table and necessary indexes if they don't exist.
Args:
connection: Oracle database connection
Raises:
Exception: If schema initialization fails
"""
with connection.cursor() as cursor:
try:
log.info("Creating Table document_chunk")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE IF NOT EXISTS document_chunk (
id VARCHAR2(255) PRIMARY KEY,
collection_name VARCHAR2(255) NOT NULL,
text CLOB,
vmetadata JSON,
vector vector(*, float32)
)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
log.info("Creating Index document_chunk_collection_name_idx")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
ON document_chunk (collection_name)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
ON document_chunk(vector)
ORGANIZATION NEIGHBOR PARTITIONS
DISTANCE COSINE
WITH TARGET ACCURACY 95
PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 100)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
connection.commit()
log.info("Database initialization completed successfully.")
except Exception as e:
connection.rollback()
log.exception(f"Error during database initialization: {e}")
raise
def check_vector_length(self) -> None:
"""
Check vector length compatibility (placeholder).
This method would check if the configured vector length matches the database schema.
Currently implemented as a placeholder.
"""
pass
def _vector_to_blob(self, vector: List[float]) -> bytes:
"""
Convert a vector to Oracle BLOB format.
Args:
vector (List[float]): The vector to convert
Returns:
bytes: The vector in Oracle BLOB format
"""
return array.array("f", vector)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
"""
Adjust vector to the expected length if needed.
Args:
vector (List[float]): The vector to adjust
Returns:
List[float]: The adjusted vector
"""
return vector
def _decimal_handler(self, obj):
"""
Handle Decimal objects for JSON serialization.
Args:
obj: Object to serialize
Returns:
float: Converted decimal value
Raises:
TypeError: If object is not JSON serializable
"""
if isinstance(obj, Decimal):
return float(obj)
raise TypeError(f"{obj} is not JSON serializable")
def _metadata_to_json(self, metadata: Dict) -> str:
"""
Convert metadata dictionary to JSON string.
Args:
metadata (Dict): Metadata dictionary
Returns:
str: JSON representation of metadata
"""
return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}"
def _json_to_metadata(self, json_str: str) -> Dict:
"""
Convert JSON string to metadata dictionary.
Args:
json_str (str): JSON string
Returns:
Dict: Metadata dictionary
"""
return json.loads(json_str) if json_str else {}
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert vector items into the database.
Args:
collection_name (str): Name of the collection
items (List[VectorItem]): List of vector items to insert
Raises:
Exception: If insertion fails
Example:
>>> client = Oracle23aiClient()
>>> items = [
... {"id": "1", "text": "Sample text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
... {"id": "2", "text": "Another text", "vector": [0.3, 0.4, ...], "metadata": {"source": "doc2"}}
... ]
>>> client.insert("my_collection", items)
"""
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
with connection.cursor() as cursor:
for item in items:
vector_blob = self._vector_to_blob(item["vector"])
metadata_json = self._metadata_to_json(item["metadata"])
cursor.execute(
"""
INSERT INTO document_chunk
(id, collection_name, text, vmetadata, vector)
VALUES (:id, :collection_name, :text, :metadata, :vector)
""",
{
"id": item["id"],
"collection_name": collection_name,
"text": item["text"],
"metadata": metadata_json,
"vector": vector_blob,
},
)
connection.commit()
log.info(
f"Successfully inserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e:
connection.rollback()
log.exception(f"Error during insert: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Update or insert vector items into the database.
If an item with the same ID exists, it will be updated;
otherwise, it will be inserted.
Args:
collection_name (str): Name of the collection
items (List[VectorItem]): List of vector items to upsert
Raises:
Exception: If upsert operation fails
Example:
>>> client = Oracle23aiClient()
>>> items = [
... {"id": "1", "text": "Updated text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
... {"id": "3", "text": "New item", "vector": [0.5, 0.6, ...], "metadata": {"source": "doc3"}}
... ]
>>> client.upsert("my_collection", items)
"""
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
with connection.cursor() as cursor:
for item in items:
vector_blob = self._vector_to_blob(item["vector"])
metadata_json = self._metadata_to_json(item["metadata"])
cursor.execute(
"""
MERGE INTO document_chunk d
USING (SELECT :merge_id as id FROM dual) s
ON (d.id = s.id)
WHEN MATCHED THEN
UPDATE SET
collection_name = :upd_collection_name,
text = :upd_text,
vmetadata = :upd_metadata,
vector = :upd_vector
WHEN NOT MATCHED THEN
INSERT (id, collection_name, text, vmetadata, vector)
VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector)
""",
{
"merge_id": item["id"],
"upd_collection_name": collection_name,
"upd_text": item["text"],
"upd_metadata": metadata_json,
"upd_vector": vector_blob,
"ins_id": item["id"],
"ins_collection_name": collection_name,
"ins_text": item["text"],
"ins_metadata": metadata_json,
"ins_vector": vector_blob,
},
)
connection.commit()
log.info(
f"Successfully upserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e:
connection.rollback()
log.exception(f"Error during upsert: {e}")
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
"""
Search for similar vectors in the database.
Performs vector similarity search using cosine distance.
Args:
collection_name (str): Name of the collection to search
vectors (List[List[Union[float, int]]]): Query vectors to find similar items for
limit (int): Maximum number of results to return per query
Returns:
Optional[SearchResult]: Search results containing ids, distances, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> query_vector = [0.1, 0.2, 0.3, ...] # Must match VECTOR_LENGTH
>>> results = client.search("my_collection", [query_vector], limit=5)
>>> if results:
... log.info(f"Found {len(results.ids[0])} matches")
... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
... log.info(f"Match {i+1}: id={id}, distance={dist}")
"""
log.info(
f"Searching items from collection '{collection_name}' with limit {limit}."
)
try:
if not vectors:
log.warning("No vectors provided for search.")
return None
num_queries = len(vectors)
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
with self.get_connection() as connection:
with connection.cursor() as cursor:
for qid, vector in enumerate(vectors):
vector_blob = self._vector_to_blob(vector)
cursor.execute(
"""
SELECT dc.id, dc.text,
JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
FROM document_chunk dc
WHERE dc.collection_name = :collection_name
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
FETCH APPROX FIRST :limit ROWS ONLY
""",
{
"query_vector": vector_blob,
"collection_name": collection_name,
"limit": limit,
},
)
results = cursor.fetchall()
for row in results:
ids[qid].append(row[0])
documents[qid].append(
row[1].read()
if isinstance(row[1], oracledb.LOB)
else str(row[1])
)
# 🔧 FIXED: Parse JSON metadata properly
metadata_str = (
row[2].read()
if isinstance(row[2], oracledb.LOB)
else row[2]
)
metadatas[qid].append(self._json_to_metadata(metadata_str))
distances[qid].append(float(row[3]))
log.info(
f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results."
)
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
log.exception(f"Error during search: {e}")
return None
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
"""
Query items based on metadata filters.
Retrieves items that match specified metadata criteria.
Args:
collection_name (str): Name of the collection to query
filter (Dict[str, Any]): Metadata filters to apply
limit (Optional[int]): Maximum number of results to return
Returns:
Optional[GetResult]: Query results containing ids, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> filter = {"source": "doc1", "category": "finance"}
>>> results = client.query("my_collection", filter, limit=20)
>>> if results:
... print(f"Found {len(results.ids[0])} matching documents")
"""
log.info(f"Querying items from collection '{collection_name}' with filters.")
try:
limit = limit or 100
query = """
SELECT id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
FROM document_chunk
WHERE collection_name = :collection_name
"""
params = {"collection_name": collection_name}
for i, (key, value) in enumerate(filter.items()):
param_name = f"value_{i}"
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
params[param_name] = str(value)
query += " FETCH FIRST :limit ROWS ONLY"
params["limit"] = limit
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(query, params)
results = cursor.fetchall()
if not results:
log.info("No results found for query.")
return None
ids = [[row[0] for row in results]]
documents = [
[
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
for row in results
]
]
# 🔧 FIXED: Parse JSON metadata properly
metadatas = [
[
self._json_to_metadata(
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
)
for row in results
]
]
log.info(f"Query completed. Found {len(results)} results.")
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
log.exception(f"Error during query: {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
"""
Get all items in a collection.
Retrieves items from a specified collection up to the limit.
Args:
collection_name (str): Name of the collection to retrieve
limit (Optional[int]): Maximum number of items to retrieve
Returns:
Optional[GetResult]: Result containing ids, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> results = client.get("my_collection", limit=50)
>>> if results:
... print(f"Retrieved {len(results.ids[0])} documents from collection")
"""
log.info(
f"Getting items from collection '{collection_name}' with limit {limit}."
)
try:
limit = limit or 1000
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT /*+ MONITOR */ id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
FROM document_chunk
WHERE collection_name = :collection_name
FETCH FIRST :limit ROWS ONLY
""",
{"collection_name": collection_name, "limit": limit},
)
results = cursor.fetchall()
if not results:
log.info("No results found.")
return None
ids = [[row[0] for row in results]]
documents = [
[
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
for row in results
]
]
# 🔧 FIXED: Parse JSON metadata properly
metadatas = [
[
self._json_to_metadata(
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
)
for row in results
]
]
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
log.exception(f"Error during get: {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
"""
Delete items from the database.
Deletes items from a collection based on IDs or metadata filters.
Args:
collection_name (str): Name of the collection to delete from
ids (Optional[List[str]]): Specific item IDs to delete
filter (Optional[Dict[str, Any]]): Metadata filters for deletion
Raises:
Exception: If deletion fails
Example:
>>> client = Oracle23aiClient()
>>> # Delete specific items by ID
>>> client.delete("my_collection", ids=["1", "3", "5"])
>>> # Or delete by metadata filter
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
"""
log.info(f"Deleting items from collection '{collection_name}'.")
try:
query = (
"DELETE FROM document_chunk WHERE collection_name = :collection_name"
)
params = {"collection_name": collection_name}
if ids:
# 🔧 FIXED: Use proper parameterized query to prevent SQL injection
placeholders = ",".join([f":id_{i}" for i in range(len(ids))])
query += f" AND id IN ({placeholders})"
for i, id_val in enumerate(ids):
params[f"id_{i}"] = id_val
if filter:
for i, (key, value) in enumerate(filter.items()):
param_name = f"value_{i}"
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
params[param_name] = str(value)
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(query, params)
deleted = cursor.rowcount
connection.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e:
log.exception(f"Error during delete: {e}")
raise
def reset(self) -> None:
"""
Reset the database by deleting all items.
Deletes all items from the document_chunk table.
Raises:
Exception: If reset fails
Example:
>>> client = Oracle23aiClient()
>>> client.reset() # Warning: Removes all data!
"""
log.info("Resetting database - deleting all items.")
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM document_chunk")
deleted = cursor.rowcount
connection.commit()
log.info(
f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
)
except Exception as e:
log.exception(f"Error during reset: {e}")
raise
def close(self) -> None:
"""
Close the database connection pool.
Properly closes the connection pool and releases all resources.
Example:
>>> client = Oracle23aiClient()
>>> # After finishing all operations
>>> client.close()
"""
try:
if hasattr(self, "pool") and self.pool:
self.pool.close()
log.info("Oracle Vector Search connection pool closed.")
except Exception as e:
log.exception(f"Error closing connection pool: {e}")
def has_collection(self, collection_name: str) -> bool:
"""
Check if a collection exists.
Args:
collection_name (str): Name of the collection to check
Returns:
bool: True if the collection exists, False otherwise
Example:
>>> client = Oracle23aiClient()
>>> if client.has_collection("my_collection"):
... print("Collection exists!")
... else:
... print("Collection does not exist.")
"""
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT COUNT(*)
FROM document_chunk
WHERE collection_name = :collection_name
FETCH FIRST 1 ROWS ONLY
""",
{"collection_name": collection_name},
)
count = cursor.fetchone()[0]
return count > 0
except Exception as e:
log.exception(f"Error checking collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
"""
Delete an entire collection.
Removes all items belonging to the specified collection.
Args:
collection_name (str): Name of the collection to delete
Example:
>>> client = Oracle23aiClient()
>>> client.delete_collection("obsolete_collection")
"""
log.info(f"Deleting collection '{collection_name}'.")
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
DELETE FROM document_chunk
WHERE collection_name = :collection_name
""",
{"collection_name": collection_name},
)
deleted = cursor.rowcount
connection.commit()
log.info(
f"Collection '{collection_name}' deleted. Removed {deleted} items."
)
except Exception as e:
log.exception(f"Error deleting collection '{collection_name}': {e}")
raise
@@ -26,6 +26,8 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -35,6 +37,7 @@ from open_webui.retrieval.vector.main import (
from open_webui.config import (
PGVECTOR_DB_URL,
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
PGVECTOR_CREATE_EXTENSION,
PGVECTOR_PGCRYPTO,
PGVECTOR_PGCRYPTO_KEY,
PGVECTOR_POOL_SIZE,
@@ -109,11 +112,36 @@ class PgvectorClient(VectorDBBase):
try:
# Ensure the pgvector extension is available
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
# Use a conditional check to avoid permission issues on Azure PostgreSQL
if PGVECTOR_CREATE_EXTENSION:
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
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto;"))
# Use a conditional check to avoid permission issues on Azure PostgreSQL
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(
@@ -201,6 +229,8 @@ class PgvectorClient(VectorDBBase):
for item in items:
vector = self.adjust_vector_length(item["vector"])
# Use raw SQL for BYTEA/pgcrypto
# Ensure metadata is converted to its JSON text representation
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -209,7 +239,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
pgp_sym_encrypt(:metadata::text, :key)
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
@@ -219,7 +249,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
"metadata": json.dumps(item["metadata"]),
"metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -235,7 +265,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=item["metadata"],
vmetadata=stringify_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@@ -253,6 +283,7 @@ class PgvectorClient(VectorDBBase):
if PGVECTOR_PGCRYPTO:
for item in items:
vector = self.adjust_vector_length(item["vector"])
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -261,7 +292,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
pgp_sym_encrypt(:metadata::text, :key)
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO UPDATE SET
vector = EXCLUDED.vector,
@@ -275,7 +306,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
"metadata": json.dumps(item["metadata"]),
"metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -292,7 +323,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = item["metadata"]
existing.vmetadata = stringify_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@@ -302,7 +333,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=item["metadata"],
vmetadata=stringify_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
@@ -416,10 +447,12 @@ class PgvectorClient(VectorDBBase):
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
self.session.rollback() # read-only transaction
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
self.session.rollback()
log.exception(f"Error during search: {e}")
return None
@@ -472,12 +505,14 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback() # read-only transaction
return GetResult(
ids=ids,
documents=documents,
metadatas=metadatas,
)
except Exception as e:
self.session.rollback()
log.exception(f"Error during query: {e}")
return None
@@ -518,8 +553,10 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback() # read-only transaction
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Error during get: {e}")
return None
@@ -587,8 +624,10 @@ class PgvectorClient(VectorDBBase):
.first()
is not None
)
self.session.rollback() # read-only transaction
return exists
except Exception as e:
self.session.rollback()
log.exception(f"Error checking collection existence: {e}")
return False
@@ -32,6 +32,8 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import stringify_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@@ -183,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
"metadata": metadata,
"metadata": stringify_metadata(metadata),
}
points.append(point)
return points
@@ -19,6 +19,8 @@ from open_webui.config import (
QDRANT_GRPC_PORT,
QDRANT_PREFER_GRPC,
QDRANT_COLLECTION_PREFIX,
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -36,6 +38,8 @@ class QdrantClient(VectorDBBase):
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
self.client = None
@@ -53,9 +57,14 @@ class QdrantClient(VectorDBBase):
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
else:
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
self.client = Qclient(
url=self.QDRANT_URI,
api_key=self.QDRANT_API_KEY,
timeout=QDRANT_TIMEOUT,
)
def _result_to_get_result(self, points) -> GetResult:
ids = []
@@ -85,6 +94,9 @@ class QdrantClient(VectorDBBase):
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
hnsw_config=models.HnswConfigDiff(
m=self.QDRANT_HNSW_M,
),
)
# Create payload indexes for efficient filtering
@@ -171,23 +183,23 @@ class QdrantClient(VectorDBBase):
)
)
points = self.client.query_points(
points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
query_filter=models.Filter(should=field_conditions),
scroll_filter=models.Filter(should=field_conditions),
limit=limit,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
except Exception as e:
log.exception(f"Error querying a collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
# Get all the items in the collection.
points = self.client.query_points(
points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
limit=NO_LIMIT, # otherwise qdrant would set limit to 10!
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created.
@@ -10,6 +10,8 @@ from open_webui.config import (
QDRANT_PREFER_GRPC,
QDRANT_URI,
QDRANT_COLLECTION_PREFIX,
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
@@ -51,6 +53,8 @@ class QdrantClient(VectorDBBase):
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
raise ValueError(
@@ -69,9 +73,14 @@ class QdrantClient(VectorDBBase):
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
if self.PREFER_GRPC
else Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
else Qclient(
url=self.QDRANT_URI,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
)
# Main collection types for multi-tenancy
@@ -133,6 +142,12 @@ class QdrantClient(VectorDBBase):
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
# Disable global index building due to multitenancy
# For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
hnsw_config=models.HnswConfigDiff(
payload_m=self.QDRANT_HNSW_M,
m=0,
),
)
log.info(
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
@@ -278,12 +293,12 @@ class QdrantClient(VectorDBBase):
tenant_filter = _tenant_filter(tenant_id)
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
points = self.client.query_points(
points = self.client.scroll(
collection_name=mt_collection,
query_filter=combined_filter,
scroll_filter=combined_filter,
limit=limit,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
def get(self, collection_name: str) -> Optional[GetResult]:
"""
@@ -296,12 +311,12 @@ class QdrantClient(VectorDBBase):
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
return None
tenant_filter = _tenant_filter(tenant_id)
points = self.client.query_points(
points = self.client.scroll(
collection_name=mt_collection,
query_filter=models.Filter(must=[tenant_filter]),
scroll_filter=models.Filter(must=[tenant_filter]),
limit=NO_LIMIT,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
def upsert(self, collection_name: str, items: List[VectorItem]):
"""
@@ -0,0 +1,775 @@
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
GetResult,
SearchResult,
)
from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION
from open_webui.env import SRC_LOG_LEVELS
from typing import List, Optional, Dict, Any, Union
import logging
import boto3
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class S3VectorClient(VectorDBBase):
"""
AWS S3 Vector integration for Open WebUI Knowledge.
"""
def __init__(self):
self.bucket_name = S3_VECTOR_BUCKET_NAME
self.region = S3_VECTOR_REGION
# Simple validation - log warnings instead of raising exceptions
if not self.bucket_name:
log.warning("S3_VECTOR_BUCKET_NAME not set - S3Vector will not work")
if not self.region:
log.warning("S3_VECTOR_REGION not set - S3Vector will not work")
if self.bucket_name and self.region:
try:
self.client = boto3.client("s3vectors", region_name=self.region)
log.info(
f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'"
)
except Exception as e:
log.error(f"Failed to initialize S3Vector client: {e}")
self.client = None
else:
self.client = None
def _create_index(
self,
index_name: str,
dimension: int,
data_type: str = "float32",
distance_metric: str = "cosine",
) -> None:
"""
Create a new index in the S3 vector bucket for the given collection if it does not exist.
"""
if self.has_collection(index_name):
log.debug(f"Index '{index_name}' already exists, skipping creation")
return
try:
self.client.create_index(
vectorBucketName=self.bucket_name,
indexName=index_name,
dataType=data_type,
dimension=dimension,
distanceMetric=distance_metric,
)
log.info(
f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})"
)
except Exception as e:
log.error(f"Error creating S3 index '{index_name}': {e}")
raise
def _filter_metadata(
self, metadata: Dict[str, Any], item_id: str
) -> Dict[str, Any]:
"""
Filter vector metadata keys to comply with S3 Vector API limit of 10 keys maximum.
"""
if not isinstance(metadata, dict) or len(metadata) <= 10:
return metadata
# Keep only the first 10 keys, prioritizing important ones based on actual Open WebUI metadata
important_keys = [
"text", # The actual document content
"file_id", # File ID
"source", # Document source file
"title", # Document title
"page", # Page number
"total_pages", # Total pages in document
"embedding_config", # Embedding configuration
"created_by", # User who created it
"name", # Document name
"hash", # Content hash
]
filtered_metadata = {}
# First, add important keys if they exist
for key in important_keys:
if key in metadata:
filtered_metadata[key] = metadata[key]
if len(filtered_metadata) >= 10:
break
# If we still have room, add other keys
if len(filtered_metadata) < 10:
for key, value in metadata.items():
if key not in filtered_metadata:
filtered_metadata[key] = value
if len(filtered_metadata) >= 10:
break
log.warning(
f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys"
)
return filtered_metadata
def has_collection(self, collection_name: str) -> bool:
"""
Check if a vector index (collection) exists in the S3 vector bucket.
"""
try:
response = self.client.list_indexes(vectorBucketName=self.bucket_name)
indexes = response.get("indexes", [])
return any(idx.get("indexName") == collection_name for idx in indexes)
except Exception as e:
log.error(f"Error listing indexes: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
"""
Delete an entire S3 Vector index/collection.
"""
if not self.has_collection(collection_name):
log.warning(
f"Collection '{collection_name}' does not exist, nothing to delete"
)
return
try:
log.info(f"Deleting collection '{collection_name}'")
self.client.delete_index(
vectorBucketName=self.bucket_name, indexName=collection_name
)
log.info(f"Successfully deleted collection '{collection_name}'")
except Exception as e:
log.error(f"Error deleting collection '{collection_name}': {e}")
raise
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert vector items into the S3 Vector index. Create index if it does not exist.
"""
if not items:
log.warning("No items to insert")
return
dimension = len(items[0]["vector"])
try:
if not self.has_collection(collection_name):
log.info(f"Index '{collection_name}' does not exist. Creating index.")
self._create_index(
index_name=collection_name,
dimension=dimension,
data_type="float32",
distance_metric="cosine",
)
# Prepare vectors for insertion
vectors = []
for item in items:
# Ensure vector data is in the correct format for S3 Vector API
vector_data = item["vector"]
if isinstance(vector_data, list):
# Convert list to float32 values as required by S3 Vector API
vector_data = [float(x) for x in vector_data]
# Prepare metadata, ensuring the text field is preserved
metadata = item.get("metadata", {}).copy()
# Add the text field to metadata so it's available for retrieval
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
vectors.append(
{
"key": item["id"],
"data": {"float32": vector_data},
"metadata": metadata,
}
)
# Insert vectors in batches of 500 (S3 Vector API limit)
batch_size = 500
for i in range(0, len(vectors), batch_size):
batch = vectors[i : i + batch_size]
self.client.put_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
vectors=batch,
)
log.info(
f"Inserted batch {i//batch_size + 1}: {len(batch)} vectors into index '{collection_name}'."
)
log.info(
f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'."
)
except Exception as e:
log.error(f"Error inserting vectors: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert or update vector items in the S3 Vector index. Create index if it does not exist.
"""
if not items:
log.warning("No items to upsert")
return
dimension = len(items[0]["vector"])
log.info(f"Upsert dimension: {dimension}")
try:
if not self.has_collection(collection_name):
log.info(
f"Index '{collection_name}' does not exist. Creating index for upsert."
)
self._create_index(
index_name=collection_name,
dimension=dimension,
data_type="float32",
distance_metric="cosine",
)
# Prepare vectors for upsert
vectors = []
for item in items:
# Ensure vector data is in the correct format for S3 Vector API
vector_data = item["vector"]
if isinstance(vector_data, list):
# Convert list to float32 values as required by S3 Vector API
vector_data = [float(x) for x in vector_data]
# Prepare metadata, ensuring the text field is preserved
metadata = item.get("metadata", {}).copy()
# Add the text field to metadata so it's available for retrieval
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
vectors.append(
{
"key": item["id"],
"data": {"float32": vector_data},
"metadata": metadata,
}
)
# Upsert vectors in batches of 500 (S3 Vector API limit)
batch_size = 500
for i in range(0, len(vectors), batch_size):
batch = vectors[i : i + batch_size]
if i == 0: # Log sample info for first batch only
log.info(
f"Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]['key']}, data_type={type(batch[0]['data']['float32'])}, data_len={len(batch[0]['data']['float32'])}"
)
else:
log.info(
f"Upserting batch {i//batch_size + 1}: {len(batch)} vectors."
)
self.client.put_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
vectors=batch,
)
log.info(
f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'."
)
except Exception as e:
log.error(f"Error upserting vectors: {e}")
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
"""
Search for similar vectors in a collection using multiple query vectors.
"""
if not self.has_collection(collection_name):
log.warning(f"Collection '{collection_name}' does not exist")
return None
if not vectors:
log.warning("No query vectors provided")
return None
try:
log.info(
f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}"
)
# Initialize result lists
all_ids = []
all_documents = []
all_metadatas = []
all_distances = []
# Process each query vector
for i, query_vector in enumerate(vectors):
log.debug(f"Processing query vector {i+1}/{len(vectors)}")
# Prepare the query vector in S3 Vector format
query_vector_dict = {"float32": [float(x) for x in query_vector]}
# Call S3 Vector query API
response = self.client.query_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
topK=limit,
queryVector=query_vector_dict,
returnMetadata=True,
returnDistance=True,
)
# Process results for this query
query_ids = []
query_documents = []
query_metadatas = []
query_distances = []
result_vectors = response.get("vectors", [])
for vector in result_vectors:
vector_id = vector.get("key")
vector_metadata = vector.get("metadata", {})
vector_distance = vector.get("distance", 0.0)
# Extract document text from metadata
document_text = ""
if isinstance(vector_metadata, dict):
# Get the text field first (highest priority)
document_text = vector_metadata.get("text")
if not document_text:
# Fallback to other possible text fields
document_text = (
vector_metadata.get("content")
or vector_metadata.get("document")
or vector_id
)
else:
document_text = vector_id
query_ids.append(vector_id)
query_documents.append(document_text)
query_metadatas.append(vector_metadata)
query_distances.append(vector_distance)
# Add this query's results to the overall results
all_ids.append(query_ids)
all_documents.append(query_documents)
all_metadatas.append(query_metadatas)
all_distances.append(query_distances)
log.info(f"Search completed. Found results for {len(all_ids)} queries")
# Return SearchResult format
return SearchResult(
ids=all_ids if all_ids else None,
documents=all_documents if all_documents else None,
metadatas=all_metadatas if all_metadatas else None,
distances=all_distances if all_distances else None,
)
except Exception as e:
log.error(f"Error searching collection '{collection_name}': {str(e)}")
# Handle specific AWS exceptions
if hasattr(e, "response") and "Error" in e.response:
error_code = e.response["Error"]["Code"]
if error_code == "NotFoundException":
log.warning(f"Collection '{collection_name}' not found")
return None
elif error_code == "ValidationException":
log.error(f"Invalid query vector dimensions or parameters")
return None
elif error_code == "AccessDeniedException":
log.error(
f"Access denied for collection '{collection_name}'. Check permissions."
)
return None
raise
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
"""
Query vectors from a collection using metadata filter.
"""
if not self.has_collection(collection_name):
log.warning(f"Collection '{collection_name}' does not exist")
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
if not filter:
log.warning("No filter provided, returning all vectors")
return self.get(collection_name)
try:
log.info(f"Querying collection '{collection_name}' with filter: {filter}")
# For S3 Vector, we need to use list_vectors and then filter results
# Since S3 Vector may not support complex server-side filtering,
# we'll retrieve all vectors and filter client-side
# Get all vectors first
all_vectors_result = self.get(collection_name)
if not all_vectors_result or not all_vectors_result.ids:
log.warning("No vectors found in collection")
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
# Extract the lists from the result
all_ids = all_vectors_result.ids[0] if all_vectors_result.ids else []
all_documents = (
all_vectors_result.documents[0] if all_vectors_result.documents else []
)
all_metadatas = (
all_vectors_result.metadatas[0] if all_vectors_result.metadatas else []
)
# Apply client-side filtering
filtered_ids = []
filtered_documents = []
filtered_metadatas = []
for i, metadata in enumerate(all_metadatas):
if self._matches_filter(metadata, filter):
if i < len(all_ids):
filtered_ids.append(all_ids[i])
if i < len(all_documents):
filtered_documents.append(all_documents[i])
filtered_metadatas.append(metadata)
# Apply limit if specified
if limit and len(filtered_ids) >= limit:
break
log.info(
f"Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total"
)
# Return GetResult format
if filtered_ids:
return GetResult(
ids=[filtered_ids],
documents=[filtered_documents],
metadatas=[filtered_metadatas],
)
else:
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
except Exception as e:
log.error(f"Error querying collection '{collection_name}': {str(e)}")
# Handle specific AWS exceptions
if hasattr(e, "response") and "Error" in e.response:
error_code = e.response["Error"]["Code"]
if error_code == "NotFoundException":
log.warning(f"Collection '{collection_name}' not found")
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
elif error_code == "AccessDeniedException":
log.error(
f"Access denied for collection '{collection_name}'. Check permissions."
)
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
raise
def get(self, collection_name: str) -> Optional[GetResult]:
"""
Retrieve all vectors from a collection.
"""
if not self.has_collection(collection_name):
log.warning(f"Collection '{collection_name}' does not exist")
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
try:
log.info(f"Retrieving all vectors from collection '{collection_name}'")
# Initialize result lists
all_ids = []
all_documents = []
all_metadatas = []
# Handle pagination
next_token = None
while True:
# Prepare request parameters
request_params = {
"vectorBucketName": self.bucket_name,
"indexName": collection_name,
"returnData": False, # Don't include vector data (not needed for get)
"returnMetadata": True, # Include metadata
"maxResults": 500, # Use reasonable page size
}
if next_token:
request_params["nextToken"] = next_token
# Call S3 Vector API
response = self.client.list_vectors(**request_params)
# Process vectors in this page
vectors = response.get("vectors", [])
for vector in vectors:
vector_id = vector.get("key")
vector_data = vector.get("data", {})
vector_metadata = vector.get("metadata", {})
# Extract the actual vector array
vector_array = vector_data.get("float32", [])
# For documents, we try to extract text from metadata or use the vector ID
document_text = ""
if isinstance(vector_metadata, dict):
# Get the text field first (highest priority)
document_text = vector_metadata.get("text")
if not document_text:
# Fallback to other possible text fields
document_text = (
vector_metadata.get("content")
or vector_metadata.get("document")
or vector_id
)
# Log the actual content for debugging
log.debug(
f"Document text preview (first 200 chars): {str(document_text)[:200]}"
)
else:
document_text = vector_id
all_ids.append(vector_id)
all_documents.append(document_text)
all_metadatas.append(vector_metadata)
# Check if there are more pages
next_token = response.get("nextToken")
if not next_token:
break
log.info(
f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'"
)
# Return in GetResult format
# The Open WebUI GetResult expects lists of lists, so we wrap each list
if all_ids:
return GetResult(
ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas]
)
else:
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
except Exception as e:
log.error(
f"Error retrieving vectors from collection '{collection_name}': {str(e)}"
)
# Handle specific AWS exceptions
if hasattr(e, "response") and "Error" in e.response:
error_code = e.response["Error"]["Code"]
if error_code == "NotFoundException":
log.warning(f"Collection '{collection_name}' not found")
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
elif error_code == "AccessDeniedException":
log.error(
f"Access denied for collection '{collection_name}'. Check permissions."
)
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
raise
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
"""
Delete vectors by ID or filter from a collection.
"""
if not self.has_collection(collection_name):
log.warning(
f"Collection '{collection_name}' does not exist, nothing to delete"
)
return
# Check if this is a knowledge collection (not file-specific)
is_knowledge_collection = not collection_name.startswith("file-")
try:
if ids:
# Delete by specific vector IDs/keys
log.info(
f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'"
)
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=ids,
)
log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
elif filter:
# Handle filter-based deletion
log.info(
f"Deleting vectors by filter from collection '{collection_name}': {filter}"
)
# If this is a knowledge collection and we have a file_id filter,
# also clean up the corresponding file-specific collection
if is_knowledge_collection and "file_id" in filter:
file_id = filter["file_id"]
file_collection_name = f"file-{file_id}"
if self.has_collection(file_collection_name):
log.info(
f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
)
self.delete_collection(file_collection_name)
# For the main collection, implement query-then-delete
# First, query to get IDs matching the filter
query_result = self.query(collection_name, filter)
if query_result and query_result.ids and query_result.ids[0]:
matching_ids = query_result.ids[0]
log.info(
f"Found {len(matching_ids)} vectors matching filter, deleting them"
)
# Delete the matching vectors by ID
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=matching_ids,
)
log.info(
f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter"
)
else:
log.warning("No vectors found matching the filter criteria")
else:
log.warning("No IDs or filter provided for deletion")
except Exception as e:
log.error(
f"Error deleting vectors from collection '{collection_name}': {e}"
)
raise
def reset(self) -> None:
"""
Reset/clear all vector data. For S3 Vector, this deletes all indexes.
"""
try:
log.warning(
"Reset called - this will delete all vector indexes in the S3 bucket"
)
# List all indexes
response = self.client.list_indexes(vectorBucketName=self.bucket_name)
indexes = response.get("indexes", [])
if not indexes:
log.warning("No indexes found to delete")
return
# Delete all indexes
deleted_count = 0
for index in indexes:
index_name = index.get("indexName")
if index_name:
try:
self.client.delete_index(
vectorBucketName=self.bucket_name, indexName=index_name
)
deleted_count += 1
log.info(f"Deleted index: {index_name}")
except Exception as e:
log.error(f"Error deleting index '{index_name}': {e}")
log.info(f"Reset completed: deleted {deleted_count} indexes")
except Exception as e:
log.error(f"Error during reset: {e}")
raise
def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> bool:
"""
Check if metadata matches the given filter conditions.
"""
if not isinstance(metadata, dict) or not isinstance(filter, dict):
return False
# Check each filter condition
for key, expected_value in filter.items():
# Handle special operators
if key.startswith("$"):
if key == "$and":
# All conditions must match
if not isinstance(expected_value, list):
continue
for condition in expected_value:
if not self._matches_filter(metadata, condition):
return False
elif key == "$or":
# At least one condition must match
if not isinstance(expected_value, list):
continue
any_match = False
for condition in expected_value:
if self._matches_filter(metadata, condition):
any_match = True
break
if not any_match:
return False
continue
# Get the actual value from metadata
actual_value = metadata.get(key)
# Handle different types of expected values
if isinstance(expected_value, dict):
# Handle comparison operators
for op, op_value in expected_value.items():
if op == "$eq":
if actual_value != op_value:
return False
elif op == "$ne":
if actual_value == op_value:
return False
elif op == "$in":
if (
not isinstance(op_value, list)
or actual_value not in op_value
):
return False
elif op == "$nin":
if isinstance(op_value, list) and actual_value in op_value:
return False
elif op == "$exists":
if bool(op_value) != (key in metadata):
return False
# Add more operators as needed
else:
# Simple equality check
if actual_value != expected_value:
return False
return True
@@ -30,6 +30,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
return PineconeClient()
case VectorType.S3VECTOR:
from open_webui.retrieval.vector.dbs.s3vector import S3VectorClient
return S3VectorClient()
case VectorType.OPENSEARCH:
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
@@ -48,6 +52,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
return ChromaClient()
case VectorType.ORACLE23AI:
from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient
return Oracle23aiClient()
case _:
raise ValueError(f"Unsupported vector type: {vector_type}")
@@ -9,3 +9,5 @@ class VectorType(StrEnum):
ELASTICSEARCH = "elasticsearch"
OPENSEARCH = "opensearch"
PGVECTOR = "pgvector"
ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector"
@@ -0,0 +1,14 @@
from datetime import datetime
def stringify_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
for key, value in metadata.items():
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
return metadata
@@ -11,7 +11,10 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_duckduckgo(
query: str, count: int, filter_list: Optional[list[str]] = None
query: str,
count: int,
filter_list: Optional[list[str]] = None,
concurrent_requests: Optional[int] = None,
) -> list[SearchResult]:
"""
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
@@ -25,6 +28,9 @@ def search_duckduckgo(
# Use the DDGS context manager to create a DDGS object
search_results = []
with DDGS() as ddgs:
if concurrent_requests:
ddgs.threads = concurrent_requests
# Use the ddgs.text() method to perform the search
try:
search_results = ddgs.text(
+1 -1
View File
@@ -11,7 +11,7 @@ def get_filtered_results(results, filter_list):
return results
filtered_results = []
for result in results:
url = result.get("url") or result.get("link", "")
url = result.get("url") or result.get("link", "") or result.get("href", "")
if not validators.url(url):
continue
domain = urlparse(url).netloc
+2 -1
View File
@@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get(
url,
**(self.requests_kwargs | kwargs),
allow_redirects=False,
) as response:
if self.raise_for_status:
response.raise_for_status()
@@ -614,7 +615,7 @@ def get_web_loader(
WebLoaderClass = SafeWebBaseLoader
if WEB_LOADER_ENGINE.value == "playwright":
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
if PLAYWRIGHT_WS_URL.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
+45 -28
View File
@@ -4,7 +4,6 @@ import logging
import os
import uuid
from functools import lru_cache
from pathlib import Path
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
@@ -15,7 +14,7 @@ import aiohttp
import aiofiles
import requests
import mimetypes
from urllib.parse import quote
from urllib.parse import urljoin, quote
from fastapi import (
Depends,
@@ -550,6 +549,11 @@ def transcription_handler(request, file_path, metadata):
metadata = metadata or {}
languages = [
metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE,
None, # Always fallback to None in case transcription fails
]
if request.app.state.config.STT_ENGINE == "":
if request.app.state.faster_whisper_model is None:
request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -561,7 +565,7 @@ def transcription_handler(request, file_path, metadata):
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
language=metadata.get("language") or WHISPER_LANGUAGE,
language=languages[0],
)
log.info(
"Detected language '%s' with probability %f"
@@ -581,21 +585,26 @@ def transcription_handler(request, file_path, metadata):
elif request.app.state.config.STT_ENGINE == "openai":
r = None
try:
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers={
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
},
files={"file": (filename, open(file_path, "rb"))},
data={
for language in languages:
payload = {
"model": request.app.state.config.STT_MODEL,
**(
{"language": metadata.get("language")}
if metadata.get("language")
else {}
),
},
)
}
if language:
payload["language"] = language
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers={
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
},
files={"file": (filename, open(file_path, "rb"))},
data=payload,
)
if r.status_code == 200:
# Successful transcription
break
r.raise_for_status()
data = r.json()
@@ -637,18 +646,26 @@ def transcription_handler(request, file_path, metadata):
"Content-Type": mime,
}
# Add model if specified
params = {}
if request.app.state.config.STT_MODEL:
params["model"] = request.app.state.config.STT_MODEL
for language in languages:
params = {}
if request.app.state.config.STT_MODEL:
params["model"] = request.app.state.config.STT_MODEL
if language:
params["language"] = language
# Make request to Deepgram API
r = requests.post(
"https://api.deepgram.com/v1/listen?smart_format=true",
headers=headers,
params=params,
data=file_data,
)
if r.status_code == 200:
# Successful transcription
break
# Make request to Deepgram API
r = requests.post(
"https://api.deepgram.com/v1/listen?smart_format=true",
headers=headers,
params=params,
data=file_data,
)
r.raise_for_status()
response_data = r.json()
+46 -30
View File
@@ -15,11 +15,11 @@ from open_webui.models.auths import (
SigninResponse,
SignupForm,
UpdatePasswordForm,
UpdateProfileForm,
UserResponse,
)
from open_webui.models.users import Users
from open_webui.models.users import Users, UpdateProfileForm
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
@@ -30,6 +30,7 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -73,7 +74,13 @@ class SessionUserResponse(Token, UserResponse):
permissions: Optional[dict] = None
@router.get("/", response_model=SessionUserResponse)
class SessionUserInfoResponse(SessionUserResponse):
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
@router.get("/", response_model=SessionUserInfoResponse)
async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user)
):
@@ -121,6 +128,9 @@ async def get_session_user(
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"bio": user.bio,
"gender": user.gender,
"date_of_birth": user.date_of_birth,
"permissions": user_permissions,
}
@@ -137,7 +147,7 @@ async def update_profile(
if session_user:
user = Users.update_user_by_id(
session_user.id,
{"profile_image_url": form_data.profile_image_url, "name": form_data.name},
form_data.model_dump(),
)
if user:
return user
@@ -351,11 +361,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
user = Users.get_user_by_email(email)
if not user:
try:
user_count = Users.get_num_users()
role = (
"admin"
if user_count == 0
if not Users.has_users()
else request.app.state.config.DEFAULT_USER_ROLE
)
@@ -489,7 +497,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
if Users.get_user_by_email(admin_email.lower()):
user = Auths.authenticate_user(admin_email.lower(), admin_password)
else:
if Users.get_num_users() != 0:
if Users.has_users():
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
@@ -556,22 +564,23 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm):
has_users = Users.has_users()
if WEBUI_AUTH:
if (
not request.app.state.config.ENABLE_SIGNUP
or not request.app.state.config.ENABLE_LOGIN_FORM
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
if Users.get_num_users() != 0:
if has_users:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
user_count = Users.get_num_users()
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
@@ -581,9 +590,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
role = (
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(form_data.password.encode("utf-8")) > 72:
@@ -629,7 +636,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
)
if request.app.state.config.WEBHOOK_URL:
post_webhook(
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@@ -644,7 +651,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
user.id, request.app.state.config.USER_PERMISSIONS
)
if user_count == 0:
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
@@ -670,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
async def signout(request: Request, response: Response):
response.delete_cookie("token")
response.delete_cookie("oui-session")
response.delete_cookie("oauth_id_token")
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = request.cookies.get("oauth_id_token")
if oauth_id_token:
oauth_session_id = request.cookies.get("oauth_session_id")
if oauth_session_id:
response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id)
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session
else None
) or OPENID_PROVIDER_URL.value
if session and oauth_server_metadata_url:
oauth_id_token = session.token.get("id_token")
try:
async with ClientSession(trust_env=True) as session:
async with session.get(OPENID_PROVIDER_URL.value) as resp:
if resp.status == 200:
openid_data = await resp.json()
async with session.get(oauth_server_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
logout_url = openid_data.get("end_session_endpoint")
if logout_url:
response.delete_cookie("oauth_id_token")
if logout_url:
return JSONResponse(
status_code=200,
content={
@@ -697,15 +714,14 @@ async def signout(request: Request, response: Response):
headers=response.headers,
)
else:
raise HTTPException(
status_code=resp.status,
detail="Failed to fetch OpenID configuration",
)
raise Exception("Failed to fetch OpenID configuration")
except Exception as e:
log.error(f"OpenID signout error: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to sign out from the OpenID provider.",
headers=response.headers,
)
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
+19 -15
View File
@@ -209,7 +209,7 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
)
if webhook_url:
post_webhook(
await post_webhook(
name,
webhook_url,
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
@@ -434,13 +434,6 @@ async def update_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -452,6 +445,15 @@ async def update_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
@@ -641,13 +643,6 @@ async def delete_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -659,6 +654,15 @@ async def delete_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.delete_message_by_id(message_id)
await sio.emit(
+24 -3
View File
@@ -36,7 +36,7 @@ router = APIRouter()
@router.get("/", response_model=list[ChatTitleIdResponse])
@router.get("/list", response_model=list[ChatTitleIdResponse])
async def get_session_user_chat_list(
def get_session_user_chat_list(
user=Depends(get_verified_user), page: Optional[int] = None
):
try:
@@ -617,7 +617,18 @@ async def clone_chat_by_id(
"title": form_data.title if form_data.title else f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
chat = Chats.import_chat(
user.id,
ChatImportForm(
**{
"chat": updated_chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
}
),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
@@ -646,7 +657,17 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
"title": f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
chat = Chats.import_chat(
user.id,
ChatImportForm(
**{
"chat": updated_chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
}
),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
+7 -6
View File
@@ -7,7 +7,11 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import BannerModel
from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data
from open_webui.utils.tools import (
get_tool_server_data,
get_tool_server_url,
set_tool_servers,
)
router = APIRouter()
@@ -110,10 +114,7 @@ async def set_tool_servers_config(
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
]
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
request.app.state.config.TOOL_SERVER_CONNECTIONS
)
await set_tool_servers(request)
return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
@@ -135,7 +136,7 @@ async def verify_tool_servers_config(
elif form_data.auth_type == "session":
token = request.state.token.credentials
url = f"{form_data.url}/{form_data.path}"
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
except Exception as e:
raise HTTPException(
+10 -4
View File
@@ -129,7 +129,10 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
if user.role == "admin":
feedback = Feedbacks.get_feedback_by_id(id=id)
else:
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
if not feedback:
raise HTTPException(
@@ -143,9 +146,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
):
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
)
if user.role == "admin":
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
else:
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
)
if not feedback:
raise HTTPException(
+175 -59
View File
@@ -6,8 +6,10 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
from urllib.parse import quote
import asyncio
from fastapi import (
BackgroundTasks,
APIRouter,
Depends,
File,
@@ -18,6 +20,7 @@ from fastapi import (
status,
Query,
)
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
@@ -42,7 +45,6 @@ from pydantic import BaseModel
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
@@ -83,14 +85,86 @@ def has_access_to_file(
############################
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if any(
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
user=user,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
Files.update_file_data_by_id(
file_item.id,
{"status": "completed"},
)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
file_item.id,
{
"status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e),
},
)
@router.post("/", response_model=FileModelResponse)
def upload_file(
request: Request,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
):
return upload_file_handler(
request,
file=file,
metadata=metadata,
process=process,
process_in_background=process_in_background,
user=user,
background_tasks=background_tasks,
)
def upload_file_handler(
request: Request,
file: UploadFile = File(...),
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
internal: bool = False,
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None,
):
log.info(f"file.content_type: {file.content_type}")
@@ -112,7 +186,7 @@ def upload_file(
# Remove the leading dot from the file extension
file_extension = file_extension[1:] if file_extension else ""
if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
]
@@ -129,13 +203,16 @@ def upload_file(
id = str(uuid.uuid4())
name = filename
filename = f"{id}_{filename}"
tags = {
"OpenWebUI-User-Email": user.email,
"OpenWebUI-User-Id": user.id,
"OpenWebUI-User-Name": user.name,
"OpenWebUI-File-Id": id,
}
contents, file_path = Storage.upload_file(file.file, filename, tags)
contents, file_path = Storage.upload_file(
file.file,
filename,
{
"OpenWebUI-User-Email": user.email,
"OpenWebUI-User-Id": user.id,
"OpenWebUI-User-Name": user.name,
"OpenWebUI-File-Id": id,
},
)
file_item = Files.insert_new_file(
user.id,
@@ -144,6 +221,9 @@ def upload_file(
"id": id,
"filename": name,
"path": file_path,
"data": {
**({"status": "pending"} if process else {}),
},
"meta": {
"name": name,
"content_type": file.content_type,
@@ -153,58 +233,37 @@ def upload_file(
}
),
)
if process:
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if any(
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata)
process_file(
request,
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=id), user=user)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file_item.id}")
file_item = FileModelResponse(
**{
**file_item.model_dump(),
"error": str(e.detail) if hasattr(e, "detail") else str(e),
}
if background_tasks and process_in_background:
background_tasks.add_task(
process_uploaded_file,
request,
file,
file_path,
file_item,
file_metadata,
user,
)
if file_item:
return file_item
return {"status": True, **file_item.model_dump()}
else:
process_uploaded_file(
request,
file,
file_path,
file_item,
file_metadata,
user,
)
return {"status": True, **file_item.model_dump()}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
if file_item:
return file_item
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
except Exception as e:
log.exception(e)
@@ -331,6 +390,63 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
)
@router.get("/{id}/process/status")
async def get_file_process_status(
id: str, stream: bool = Query(False), user=Depends(get_verified_user)
):
file = Files.get_file_by_id(id)
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
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)
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")
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
break
await asyncio.sleep(0.5)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
return StreamingResponse(
event_stream(file),
media_type="text/event-stream",
)
else:
return {"status": file.data.get("status", "pending")}
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Get File Data Content By Id
############################
+45 -14
View File
@@ -10,10 +10,14 @@ import mimetypes
from open_webui.models.folders import (
FolderForm,
FolderUpdateForm,
FolderModel,
Folders,
)
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
@@ -44,6 +48,31 @@ router = APIRouter()
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
# Verify folder data integrity
for folder in folders:
if folder.data:
if "files" in folder.data:
valid_files = []
for file in folder.data["files"]:
if file.get("type") == "file":
if Files.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
else:
valid_files.append(file)
folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data)
)
return [
{
**folder.model_dump(),
@@ -113,22 +142,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderForm, user=Depends(get_verified_user)
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
if form_data.name is not None:
# Check if folder with same name exists
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
return folder
except Exception as e:
log.exception(e)
@@ -244,11 +275,11 @@ async def delete_folder_by_id(
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
result = Folders.delete_folder_by_id_and_user_id(id, user.id)
if result:
return result
else:
raise Exception("Error deleting folder")
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
for folder_id in folder_ids:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
return True
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
+22 -7
View File
@@ -10,6 +10,7 @@ from open_webui.models.functions import (
FunctionForm,
FunctionModel,
FunctionResponse,
FunctionWithValvesModel,
Functions,
)
from open_webui.utils.plugin import (
@@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)):
############################
@router.get("/export", response_model=list[FunctionModel])
async def get_functions(user=Depends(get_admin_user)):
return Functions.get_functions()
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
return Functions.get_functions(include_valves=include_valves)
############################
@@ -131,15 +132,29 @@ async def load_function_from_url(
############################
class SyncFunctionsForm(FunctionForm):
functions: list[FunctionModel] = []
class SyncFunctionsForm(BaseModel):
functions: list[FunctionWithValvesModel] = []
@router.post("/sync", response_model=Optional[FunctionModel])
@router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
return Functions.sync_functions(user.id, form_data.functions)
try:
for function in form_data.functions:
function.content = replace_imports(function.content)
function_module, function_type, frontmatter = load_function_module_by_id(
function.id,
content=function.content,
)
return Functions.sync_functions(user.id, form_data.functions)
except Exception as e:
log.exception(f"Failed to load a function: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
############################
+41 -9
View File
@@ -10,11 +10,18 @@ from typing import Optional
from urllib.parse import quote
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
UploadFile,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
from open_webui.routers.files import upload_file
from open_webui.routers.files import upload_file_handler
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.images.comfyui import (
ComfyUIGenerateImageForm,
@@ -41,6 +48,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
},
"automatic1111": {
@@ -65,6 +73,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
class OpenAIConfigForm(BaseModel):
OPENAI_API_BASE_URL: str
OPENAI_API_VERSION: str
OPENAI_API_KEY: str
@@ -112,6 +121,9 @@ async def update_config(
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL
)
request.app.state.config.IMAGES_OPENAI_API_VERSION = (
form_data.openai.OPENAI_API_VERSION
)
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
@@ -158,6 +170,7 @@ async def update_config(
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
},
"automatic1111": {
@@ -469,7 +482,13 @@ def upload_image(request, image_data, content_type, metadata, user):
"content-type": content_type,
},
)
file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
file_item = upload_file_handler(
request,
file=file,
metadata=metadata,
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
@@ -483,11 +502,18 @@ async def image_generations(
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
# image model other than gpt-image-1, which is warned about on settings save
width, height = (
tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
if "x" in request.app.state.config.IMAGE_SIZE
else (512, 512)
)
size = "512x512"
if (
request.app.state.config.IMAGE_SIZE
and "x" in request.app.state.config.IMAGE_SIZE
):
size = request.app.state.config.IMAGE_SIZE
if form_data.size and "x" in form_data.size:
size = form_data.size
width, height = tuple(map(int, size.split("x")))
r = None
try:
@@ -524,10 +550,16 @@ async def image_generations(
),
}
api_version_query_param = ""
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
api_version_query_param = (
f"?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}"
)
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations",
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations{api_version_query_param}",
json=data,
headers=headers,
)
+43 -15
View File
@@ -1,6 +1,6 @@
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
import logging
from open_webui.models.knowledge import (
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.models.models import Models, ModelForm
@@ -42,7 +43,7 @@ router = APIRouter()
async def get_knowledge(user=Depends(get_verified_user)):
knowledge_bases = []
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
@@ -90,7 +91,7 @@ async def get_knowledge(user=Depends(get_verified_user)):
async def get_knowledge_list(user=Depends(get_verified_user)):
knowledge_bases = []
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
@@ -150,6 +151,18 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge:
@@ -284,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_by_id(
request: Request,
id: str,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
@@ -305,10 +319,22 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
files = Files.get_files_by_ids(file_ids)
files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -491,6 +517,7 @@ def update_file_from_knowledge_by_id(
def remove_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
delete_file: bool = Query(True),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
@@ -527,18 +554,19 @@ def remove_file_from_knowledge_by_id(
log.debug(e)
pass
try:
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
if delete_file:
try:
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
if knowledge:
data = knowledge.data or {}
+4
View File
@@ -82,6 +82,10 @@ class QueryMemoryForm(BaseModel):
async def query_memory(
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
):
memories = Memories.get_memories_by_user_id(user.id)
if not memories:
raise HTTPException(status_code=404, detail="No memories found for user")
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
+32 -4
View File
@@ -7,13 +7,15 @@ from open_webui.models.models import (
ModelUserResponse,
Models,
)
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
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
router = APIRouter()
@@ -25,7 +27,7 @@ router = APIRouter()
@router.get("/", response_model=list[ModelUserResponse])
async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Models.get_models()
else:
return Models.get_models_by_user_id(user.id)
@@ -78,6 +80,32 @@ async def create_new_model(
)
############################
# ExportModels
############################
@router.get("/export", response_model=list[ModelModel])
async def export_models(user=Depends(get_admin_user)):
return Models.get_models()
############################
# SyncModels
############################
class SyncModelsForm(BaseModel):
models: list[ModelModel] = []
@router.post("/sync", response_model=list[ModelModel])
async def sync_models(
request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user)
):
return Models.sync_models(user.id, form_data.models)
###########################
# GetModelById
###########################
@@ -89,7 +117,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
if (
user.role == "admin"
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or has_access(user.id, "read", model.access_control)
):
@@ -102,7 +130,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
############################
# ToggelModelById
# ToggleModelById
############################
+17 -13
View File
@@ -47,7 +47,7 @@ from open_webui.utils.misc import (
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
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
@@ -329,17 +329,21 @@ def merge_ollama_models_lists(model_lists):
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
id = model.get("model")
if id is not None:
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
@cached(ttl=MODELS_CACHE_TTL)
@cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
@@ -415,15 +419,15 @@ async def get_all_models(request: Request, user: UserModel = None):
try:
loaded_models = await get_ollama_loaded_models(request, user=user)
expires_map = {
m["name"]: m["expires_at"]
m["model"]: m["expires_at"]
for m in loaded_models["models"]
if "expires_at" in m
}
for m in models["models"]:
if m["name"] in expires_map:
if m["model"] in expires_map:
# Parse ISO8601 datetime with offset, get unix timestamp as int
dt = datetime.fromisoformat(expires_map[m["name"]])
dt = datetime.fromisoformat(expires_map[m["model"]])
m["expires_at"] = int(dt.timestamp())
except Exception as e:
log.debug(f"Failed to get loaded models: {e}")
@@ -1330,7 +1334,7 @@ async def generate_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_ollama(params, payload)
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -1519,7 +1523,7 @@ async def generate_openai_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if user.role == "user":
+214 -177
View File
@@ -2,17 +2,20 @@ import asyncio
import hashlib
import json
import logging
from pathlib import Path
from typing import Literal, Optional, overload
from typing import Optional
import aiohttp
from aiocache import cached
import requests
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi import Depends, HTTPException, Request, APIRouter
from fastapi.responses import (
FileResponse,
StreamingResponse,
JSONResponse,
PlainTextResponse,
)
from pydantic import BaseModel
from starlette.background import BackgroundTask
@@ -31,12 +34,12 @@ from open_webui.env import (
from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENV, SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
apply_system_prompt_to_body,
)
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
@@ -95,12 +98,12 @@ async def cleanup_response(
await session.close()
def openai_o_series_handler(payload):
def openai_reasoning_model_handler(payload):
"""
Handle "o" series specific parameters
Handle reasoning model specific parameters
"""
if "max_tokens" in payload:
# Convert "max_tokens" to "max_completion_tokens" for all o-series models
# Convert "max_tokens" to "max_completion_tokens" for all reasoning models
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
@@ -116,6 +119,74 @@ def openai_o_series_handler(payload):
return payload
def get_headers_and_cookies(
request: Request,
url,
key=None,
config=None,
metadata: Optional[dict] = None,
user: UserModel = None,
):
cookies = {}
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
token = None
auth_type = config.get("auth_type")
if auth_type == "bearer" or auth_type is None:
# Default to bearer if not specified
token = f"{key}"
elif auth_type == "none":
token = None
elif auth_type == "session":
cookies = request.cookies
token = request.state.token.credentials
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
if oauth_token:
token = f"{oauth_token.get('access_token', '')}"
if token:
headers["Authorization"] = f"Bearer {token}"
return headers, cookies
##########################################
#
# API routes
@@ -207,34 +278,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
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
)
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
r = None
try:
r = requests.post(
url=f"{url}/audio/speech",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
headers=headers,
cookies=cookies,
stream=True,
)
@@ -358,11 +418,22 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
for model in (
model_list = (
response if isinstance(response, list) else response.get("data", [])
):
)
if not isinstance(model_list, list):
# Catch non-list responses
model_list = []
for model in model_list:
# Remove name key if its value is None #16689
if "name" in model and model["name"] is None:
del model["name"]
if prefix_id:
model["id"] = f"{prefix_id}.{model['id']}"
model["id"] = (
f"{prefix_id}.{model.get('id', model.get('name', ''))}"
)
if tags:
model["tags"] = tags
@@ -387,7 +458,10 @@ async def get_filtered_models(models, user):
return filtered_models
@cached(ttl=MODELS_CACHE_TTL)
@cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
@@ -475,19 +549,9 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
models = {
@@ -495,11 +559,10 @@ async def get_models(
"object": "list",
}
else:
headers["Authorization"] = f"Bearer {key}"
async with session.get(
f"{url}/models",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
@@ -558,7 +621,9 @@ class ConnectionVerificationForm(BaseModel):
@router.post("/verify")
async def verify_connection(
form_data: ConnectionVerificationForm, user=Depends(get_admin_user)
request: Request,
form_data: ConnectionVerificationForm,
user=Depends(get_admin_user),
):
url = form_data.url
key = form_data.key
@@ -570,19 +635,9 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
headers["api-key"] = key
@@ -591,35 +646,47 @@ async def verify_connection(
async with session.get(
url=f"{url}/openai/models?api-version={api_version}",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
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']}"
raise Exception(error_detail)
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
)
response_data = await r.json()
return response_data
else:
headers["Authorization"] = f"Bearer {key}"
async with session.get(
f"{url}/models",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
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']}"
raise Exception(error_detail)
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
)
response_data = await r.json()
return response_data
except aiohttp.ClientError as e:
@@ -630,8 +697,9 @@ async def verify_connection(
)
except Exception as e:
log.exception(f"Unexpected error: {e}")
error_detail = f"Unexpected error: {str(e)}"
raise HTTPException(status_code=500, detail=error_detail)
raise HTTPException(
status_code=500, detail="Open WebUI: Server Connection Error"
)
def get_azure_allowed_params(api_version: str) -> set[str]:
@@ -675,6 +743,10 @@ def get_azure_allowed_params(api_version: str) -> set[str]:
return allowed_params
def is_openai_reasoning_model(model: str) -> bool:
return model.lower().startswith(("o1", "o3", "o4", "gpt-5"))
def convert_to_azure_payload(url, payload: dict, api_version: str):
model = payload.get("model", "")
@@ -682,7 +754,7 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
allowed_params = get_azure_allowed_params(api_version)
# Special handling for o-series models
if model.startswith("o") and model.endswith("-mini"):
if is_openai_reasoning_model(model):
# Convert max_tokens to max_completion_tokens for o-series models
if "max_tokens" in payload:
payload["max_completion_tokens"] = payload["max_tokens"]
@@ -732,7 +804,7 @@ async def generate_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -787,10 +859,9 @@ async def generate_chat_completion(
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Check if model is from "o" series
is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4"))
if is_o_series:
payload = openai_o_series_handler(payload)
# Check if model is a reasoning model that needs special handling
if is_openai_reasoning_model(payload["model"]):
payload = openai_reasoning_model_handler(payload)
elif "api.openai.com" not in url:
# Remove "max_completion_tokens" from the payload for backward compatibility
if "max_completion_tokens" in payload:
@@ -806,32 +877,9 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, metadata, user=user
)
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
@@ -841,7 +889,6 @@ async def generate_chat_completion(
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
headers["Authorization"] = f"Bearer {key}"
payload = json.dumps(payload)
@@ -860,6 +907,7 @@ async def generate_chat_completion(
url=request_url,
data=payload,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -881,21 +929,19 @@ async def generate_chat_completion(
log.error(e)
response = await r.text()
r.raise_for_status()
if r.status >= 400:
if isinstance(response, (dict, list)):
return JSONResponse(status_code=r.status, content=response)
else:
return PlainTextResponse(status_code=r.status, content=response)
return response
except Exception as e:
log.exception(e)
detail = None
if isinstance(response, dict):
if "error" in response:
detail = f"{response['error']['message'] if 'message' in response['error'] else response['error']}"
elif isinstance(response, str):
detail = response
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
@@ -923,33 +969,29 @@ async def embeddings(request: Request, form_data: dict, 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
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
try:
session = aiohttp.ClientSession(trust_env=True)
r = await session.request(
method="POST",
url=f"{url}/embeddings",
data=body,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
headers=headers,
cookies=cookies,
)
r.raise_for_status()
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
@@ -961,21 +1003,25 @@ async def embeddings(request: Request, form_data: dict, user):
),
)
else:
response_data = await r.json()
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)
detail = None
if r is not None:
try:
res = await r.json()
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
@@ -1005,19 +1051,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = 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")
@@ -1030,7 +1066,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
request_url = f"{url}/{path}?api-version={api_version}"
else:
headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
@@ -1039,9 +1074,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
url=request_url,
data=body,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
@@ -1055,24 +1090,26 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
),
)
else:
response_data = await r.json()
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)
detail = None
if r is not None:
try:
res = await r.json()
log.error(res)
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
+4 -3
View File
@@ -1,4 +1,5 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.models.prompts import (
PromptForm,
@@ -7,9 +8,9 @@ from open_webui.models.prompts import (
Prompts,
)
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, status, Request
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
router = APIRouter()
@@ -20,7 +21,7 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
@@ -30,7 +31,7 @@ async def get_prompts(user=Depends(get_verified_user)):
@router.get("/list", response_model=list[PromptUserResponse])
async def get_prompt_list(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
+140 -49
View File
@@ -5,7 +5,6 @@ import os
import shutil
import asyncio
import uuid
from datetime import datetime
from pathlib import Path
@@ -281,6 +280,18 @@ async def update_embedding_config(
log.info(
f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}"
)
if request.app.state.config.RAG_EMBEDDING_ENGINE == "":
# unloads current internal embedding model and clears VRAM cache
request.app.state.ef = None
request.app.state.EMBEDDING_FUNCTION = None
import gc
gc.collect()
if DEVICE_TYPE == "cuda":
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
try:
request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model
@@ -401,20 +412,27 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"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,
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
"DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
"DATALAB_MARKER_FORMAT_LINES": request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
"DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM,
"DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
"EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -447,6 +465,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV,
"WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT,
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
@@ -502,6 +521,7 @@ class WebConfig(BaseModel):
WEB_SEARCH_TRUST_ENV: Optional[bool] = None
WEB_SEARCH_RESULT_COUNT: Optional[int] = None
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
@@ -566,12 +586,14 @@ class ConfigForm(BaseModel):
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None
DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_LANGS: Optional[str] = None
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None
DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None
DATALAB_MARKER_FORCE_OCR: Optional[bool] = None
DATALAB_MARKER_PAGINATE: Optional[bool] = None
DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None
DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None
DATALAB_MARKER_USE_LLM: Optional[bool] = None
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
@@ -579,8 +601,13 @@ class ConfigForm(BaseModel):
TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None
DOCLING_DO_OCR: Optional[bool] = None
DOCLING_FORCE_OCR: Optional[bool] = None
DOCLING_OCR_ENGINE: Optional[str] = None
DOCLING_OCR_LANG: Optional[str] = None
DOCLING_PDF_BACKEND: Optional[str] = None
DOCLING_TABLE_MODE: Optional[str] = None
DOCLING_PIPELINE: Optional[str] = None
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
@@ -647,9 +674,6 @@ async def update_rag_config(
if form_data.ENABLE_RAG_HYBRID_SEARCH is not None
else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH
)
# Free up memory if hybrid search is disabled
if not request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
request.app.state.rf = None
request.app.state.config.TOP_K_RERANKER = (
form_data.TOP_K_RERANKER
@@ -683,10 +707,15 @@ async def update_rag_config(
if form_data.DATALAB_MARKER_API_KEY is not None
else request.app.state.config.DATALAB_MARKER_API_KEY
)
request.app.state.config.DATALAB_MARKER_LANGS = (
form_data.DATALAB_MARKER_LANGS
if form_data.DATALAB_MARKER_LANGS is not None
else request.app.state.config.DATALAB_MARKER_LANGS
request.app.state.config.DATALAB_MARKER_API_BASE_URL = (
form_data.DATALAB_MARKER_API_BASE_URL
if form_data.DATALAB_MARKER_API_BASE_URL is not None
else request.app.state.config.DATALAB_MARKER_API_BASE_URL
)
request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = (
form_data.DATALAB_MARKER_ADDITIONAL_CONFIG
if form_data.DATALAB_MARKER_ADDITIONAL_CONFIG is not None
else request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG
)
request.app.state.config.DATALAB_MARKER_SKIP_CACHE = (
form_data.DATALAB_MARKER_SKIP_CACHE
@@ -713,6 +742,11 @@ async def update_rag_config(
if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None
else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
request.app.state.config.DATALAB_MARKER_FORMAT_LINES = (
form_data.DATALAB_MARKER_FORMAT_LINES
if form_data.DATALAB_MARKER_FORMAT_LINES is not None
else request.app.state.config.DATALAB_MARKER_FORMAT_LINES
)
request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = (
form_data.DATALAB_MARKER_OUTPUT_FORMAT
if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None
@@ -743,6 +777,16 @@ async def update_rag_config(
if form_data.DOCLING_SERVER_URL is not None
else request.app.state.config.DOCLING_SERVER_URL
)
request.app.state.config.DOCLING_DO_OCR = (
form_data.DOCLING_DO_OCR
if form_data.DOCLING_DO_OCR is not None
else request.app.state.config.DOCLING_DO_OCR
)
request.app.state.config.DOCLING_FORCE_OCR = (
form_data.DOCLING_FORCE_OCR
if form_data.DOCLING_FORCE_OCR is not None
else request.app.state.config.DOCLING_FORCE_OCR
)
request.app.state.config.DOCLING_OCR_ENGINE = (
form_data.DOCLING_OCR_ENGINE
if form_data.DOCLING_OCR_ENGINE is not None
@@ -753,7 +797,21 @@ async def update_rag_config(
if form_data.DOCLING_OCR_LANG is not None
else request.app.state.config.DOCLING_OCR_LANG
)
request.app.state.config.DOCLING_PDF_BACKEND = (
form_data.DOCLING_PDF_BACKEND
if form_data.DOCLING_PDF_BACKEND is not None
else request.app.state.config.DOCLING_PDF_BACKEND
)
request.app.state.config.DOCLING_TABLE_MODE = (
form_data.DOCLING_TABLE_MODE
if form_data.DOCLING_TABLE_MODE is not None
else request.app.state.config.DOCLING_TABLE_MODE
)
request.app.state.config.DOCLING_PIPELINE = (
form_data.DOCLING_PIPELINE
if form_data.DOCLING_PIPELINE is not None
else request.app.state.config.DOCLING_PIPELINE
)
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
form_data.DOCLING_DO_PICTURE_DESCRIPTION
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
@@ -793,6 +851,18 @@ async def update_rag_config(
)
# Reranking settings
if request.app.state.config.RAG_RERANKING_ENGINE == "":
# Unloading the internal reranker and clear VRAM memory
request.app.state.rf = None
request.app.state.RERANKING_FUNCTION = None
import gc
gc.collect()
if DEVICE_TYPE == "cuda":
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
request.app.state.config.RAG_RERANKING_ENGINE = (
form_data.RAG_RERANKING_ENGINE
if form_data.RAG_RERANKING_ENGINE is not None
@@ -822,19 +892,23 @@ async def update_rag_config(
)
try:
request.app.state.rf = get_rf(
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
True,
)
if (
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH
and not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
):
request.app.state.rf = get_rf(
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
True,
)
request.app.state.RERANKING_FUNCTION = get_reranking_function(
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.rf,
)
request.app.state.RERANKING_FUNCTION = get_reranking_function(
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.rf,
)
except Exception as e:
log.error(f"Error loading reranking model: {e}")
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
@@ -902,6 +976,9 @@ async def update_rag_config(
request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = (
form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS
)
request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = (
form_data.web.WEB_LOADER_CONCURRENT_REQUESTS
)
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = (
form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST
)
@@ -1006,7 +1083,8 @@ async def update_rag_config(
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"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,
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
@@ -1018,8 +1096,13 @@ async def update_rag_config(
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1052,6 +1135,7 @@ async def update_rag_config(
"WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV,
"WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT,
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
@@ -1229,27 +1313,14 @@ def save_docs_to_vector_db(
{
**doc.metadata,
**(metadata if metadata else {}),
"embedding_config": json.dumps(
{
"engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
"model": request.app.state.config.RAG_EMBEDDING_MODEL,
}
),
"embedding_config": {
"engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
"model": request.app.state.config.RAG_EMBEDDING_MODEL,
},
}
for doc in docs
]
# ChromaDB does not like datetime formats
# for meta-data so convert them to string.
for metadata in metadatas:
for key, value in metadata.items():
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name):
log.info(f"collection {collection_name} already exists")
@@ -1406,12 +1477,14 @@ def process_file(
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_LANGS=request.app.state.config.DATALAB_MARKER_LANGS,
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,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
@@ -1419,8 +1492,13 @@ def process_file(
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1466,7 +1544,7 @@ def process_file(
log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id(
file.id,
{"content": text_content},
{"status": "completed", "content": text_content},
)
hash = calculate_sha256_string(text_content)
@@ -1620,7 +1698,7 @@ def process_web(
loader = get_web_loader(
form_data.url,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
)
docs = loader.load()
content = " ".join([doc.page_content for doc in docs])
@@ -1785,7 +1863,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.SERPLY_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No SERPLY_API_KEY found in environment variables")
@@ -1794,6 +1872,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
)
elif engine == "tavily":
if request.app.state.config.TAVILY_API_KEY:
@@ -1910,6 +1989,8 @@ async def process_web_search(
):
urls = []
result_items = []
try:
logging.info(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
@@ -1931,6 +2012,7 @@ async def process_web_search(
if result:
for item in result:
if item and item.link:
result_items.append(item)
urls.append(item.link)
urls = list(dict.fromkeys(urls))
@@ -1961,13 +2043,13 @@ async def process_web_search(
},
)
for result in search_results
if hasattr(result, "snippet")
if hasattr(result, "snippet") and result.snippet is not None
]
else:
loader = get_web_loader(
urls,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
@@ -1975,12 +2057,16 @@ async def process_web_search(
urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader
result_items = [
dict(item) for item in result_items if item.link in urls
] # only keep the search results that have been loaded
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
"status": True,
"collection_name": None,
"filenames": urls,
"items": result_items,
"docs": [
{
"content": doc.page_content,
@@ -2013,6 +2099,7 @@ async def process_web_search(
return {
"status": True,
"collection_names": [collection_name],
"items": result_items,
"filenames": urls,
"loaded_count": len(docs),
}
@@ -2040,7 +2127,9 @@ def query_doc_handler(
user=Depends(get_verified_user),
):
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
):
collection_results = {}
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
collection_name=form_data.collection_name
@@ -2110,7 +2199,9 @@ def query_collection_handler(
user=Depends(get_verified_user),
):
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
):
return query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
+926
View File
@@ -0,0 +1,926 @@
"""
Experimental SCIM 2.0 Implementation for Open WebUI
Provides System for Cross-domain Identity Management endpoints for users and groups
NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change.
"""
import logging
import uuid
import time
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, ConfigDict
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups, GroupModel
from open_webui.utils.auth import (
get_admin_user,
get_current_user,
decode_token,
get_verified_user,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
# SCIM 2.0 Schema URIs
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
# SCIM Resource Types
SCIM_RESOURCE_TYPE_USER = "User"
SCIM_RESOURCE_TYPE_GROUP = "Group"
def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
"""Create a SCIM-compliant error response"""
error_body = {
"schemas": [SCIM_ERROR_SCHEMA],
"status": str(status_code),
"detail": detail,
}
if scim_type:
error_body["scimType"] = scim_type
elif status_code == 404:
error_body["scimType"] = "invalidValue"
elif status_code == 409:
error_body["scimType"] = "uniqueness"
elif status_code == 400:
error_body["scimType"] = "invalidSyntax"
return JSONResponse(status_code=status_code, content=error_body)
class SCIMError(BaseModel):
"""SCIM Error Response"""
schemas: List[str] = [SCIM_ERROR_SCHEMA]
status: str
scimType: Optional[str] = None
detail: Optional[str] = None
class SCIMMeta(BaseModel):
"""SCIM Resource Metadata"""
resourceType: str
created: str
lastModified: str
location: Optional[str] = None
version: Optional[str] = None
class SCIMName(BaseModel):
"""SCIM User Name"""
formatted: Optional[str] = None
familyName: Optional[str] = None
givenName: Optional[str] = None
middleName: Optional[str] = None
honorificPrefix: Optional[str] = None
honorificSuffix: Optional[str] = None
class SCIMEmail(BaseModel):
"""SCIM Email"""
value: str
type: Optional[str] = "work"
primary: bool = True
display: Optional[str] = None
class SCIMPhoto(BaseModel):
"""SCIM Photo"""
value: str
type: Optional[str] = "photo"
primary: bool = True
display: Optional[str] = None
class SCIMGroupMember(BaseModel):
"""SCIM Group Member"""
value: str # User ID
ref: Optional[str] = Field(None, alias="$ref")
type: Optional[str] = "User"
display: Optional[str] = None
class SCIMUser(BaseModel):
"""SCIM User Resource"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
id: str
externalId: Optional[str] = None
userName: str
name: Optional[SCIMName] = None
displayName: str
emails: List[SCIMEmail]
active: bool = True
photos: Optional[List[SCIMPhoto]] = None
groups: Optional[List[Dict[str, str]]] = None
meta: SCIMMeta
class SCIMUserCreateRequest(BaseModel):
"""SCIM User Create Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
externalId: Optional[str] = None
userName: str
name: Optional[SCIMName] = None
displayName: str
emails: List[SCIMEmail]
active: bool = True
password: Optional[str] = None
photos: Optional[List[SCIMPhoto]] = None
class SCIMUserUpdateRequest(BaseModel):
"""SCIM User Update Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
id: Optional[str] = None
externalId: Optional[str] = None
userName: Optional[str] = None
name: Optional[SCIMName] = None
displayName: Optional[str] = None
emails: Optional[List[SCIMEmail]] = None
active: Optional[bool] = None
photos: Optional[List[SCIMPhoto]] = None
class SCIMGroup(BaseModel):
"""SCIM Group Resource"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
id: str
displayName: str
members: Optional[List[SCIMGroupMember]] = []
meta: SCIMMeta
class SCIMGroupCreateRequest(BaseModel):
"""SCIM Group Create Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
displayName: str
members: Optional[List[SCIMGroupMember]] = []
class SCIMGroupUpdateRequest(BaseModel):
"""SCIM Group Update Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
displayName: Optional[str] = None
members: Optional[List[SCIMGroupMember]] = None
class SCIMListResponse(BaseModel):
"""SCIM List Response"""
schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
totalResults: int
itemsPerPage: int
startIndex: int
Resources: List[Any]
class SCIMPatchOperation(BaseModel):
"""SCIM Patch Operation"""
op: str # "add", "replace", "remove"
path: Optional[str] = None
value: Optional[Any] = None
class SCIMPatchRequest(BaseModel):
"""SCIM Patch Request"""
schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
Operations: List[SCIMPatchOperation]
def get_scim_auth(
request: Request, authorization: Optional[str] = Header(None)
) -> bool:
"""
Verify SCIM authentication
Checks for SCIM-specific bearer token configured in the system
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
parts = authorization.split()
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization format. Expected: Bearer <token>",
)
scheme, token = parts
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
)
# Check if SCIM is enabled
scim_enabled = getattr(request.app.state, "SCIM_ENABLED", False)
log.info(
f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}"
)
# Handle both PersistentConfig and direct value
if hasattr(scim_enabled, "value"):
scim_enabled = scim_enabled.value
log.info(f"SCIM enabled status after conversion: {scim_enabled}")
if not scim_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SCIM is not enabled",
)
# Verify the SCIM token
scim_token = getattr(request.app.state, "SCIM_TOKEN", None)
# Handle both PersistentConfig and direct value
if hasattr(scim_token, "value"):
scim_token = scim_token.value
log.debug(f"SCIM token configured: {bool(scim_token)}")
if not scim_token or token != scim_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid SCIM token",
)
return True
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
log.error(f"SCIM authentication error: {e}")
import traceback
log.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
)
def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
"""Convert internal User model to SCIM User"""
# Parse display name into name components
name_parts = user.name.split(" ", 1) if user.name else ["", ""]
given_name = name_parts[0] if name_parts else ""
family_name = name_parts[1] if len(name_parts) > 1 else ""
# Get user's groups
user_groups = Groups.get_groups_by_member_id(user.id)
groups = [
{
"value": group.id,
"display": group.name,
"$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
"type": "direct",
}
for group in user_groups
]
return SCIMUser(
id=user.id,
userName=user.email,
name=SCIMName(
formatted=user.name,
givenName=given_name,
familyName=family_name,
),
displayName=user.name,
emails=[SCIMEmail(value=user.email)],
active=user.role != "pending",
photos=(
[SCIMPhoto(value=user.profile_image_url)]
if user.profile_image_url
else None
),
groups=groups if groups else None,
meta=SCIMMeta(
resourceType=SCIM_RESOURCE_TYPE_USER,
created=datetime.fromtimestamp(
user.created_at, tz=timezone.utc
).isoformat(),
lastModified=datetime.fromtimestamp(
user.updated_at, tz=timezone.utc
).isoformat(),
location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
),
)
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
"""Convert internal Group model to SCIM Group"""
members = []
for user_id in group.user_ids:
user = Users.get_user_by_id(user_id)
if user:
members.append(
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
)
return SCIMGroup(
id=group.id,
displayName=group.name,
members=members,
meta=SCIMMeta(
resourceType=SCIM_RESOURCE_TYPE_GROUP,
created=datetime.fromtimestamp(
group.created_at, tz=timezone.utc
).isoformat(),
lastModified=datetime.fromtimestamp(
group.updated_at, tz=timezone.utc
).isoformat(),
location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
),
)
# SCIM Service Provider Config
@router.get("/ServiceProviderConfig")
async def get_service_provider_config():
"""Get SCIM Service Provider Configuration"""
return {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": {"supported": True},
"bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576},
"filter": {"supported": True, "maxResults": 200},
"changePassword": {"supported": False},
"sort": {"supported": False},
"etag": {"supported": False},
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Authentication using OAuth 2.0 Bearer Token",
}
],
}
# SCIM Resource Types
@router.get("/ResourceTypes")
async def get_resource_types(request: Request):
"""Get SCIM Resource Types"""
return [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "User",
"name": "User",
"endpoint": "/Users",
"schema": SCIM_USER_SCHEMA,
"meta": {
"location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User",
"resourceType": "ResourceType",
},
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": SCIM_GROUP_SCHEMA,
"meta": {
"location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group",
"resourceType": "ResourceType",
},
},
]
# SCIM Schemas
@router.get("/Schemas")
async def get_schemas():
"""Get SCIM Schemas"""
return [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": SCIM_USER_SCHEMA,
"name": "User",
"description": "User Account",
"attributes": [
{
"name": "userName",
"type": "string",
"required": True,
"uniqueness": "server",
},
{"name": "displayName", "type": "string", "required": True},
{
"name": "emails",
"type": "complex",
"multiValued": True,
"required": True,
},
{"name": "active", "type": "boolean", "required": False},
],
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": SCIM_GROUP_SCHEMA,
"name": "Group",
"description": "Group",
"attributes": [
{"name": "displayName", "type": "string", "required": True},
{
"name": "members",
"type": "complex",
"multiValued": True,
"required": False,
},
],
},
]
# Users endpoints
@router.get("/Users", response_model=SCIMListResponse)
async def get_users(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
):
"""List SCIM Users"""
skip = startIndex - 1
limit = count
# Get users from database
if filter:
# Simple filter parsing - supports userName eq "email"
# In production, you'd want a more robust filter parser
if "userName eq" in filter:
email = filter.split('"')[1]
user = Users.get_user_by_email(email)
users_list = [user] if user else []
total = 1 if user else 0
else:
response = Users.get_users(skip=skip, limit=limit)
users_list = response["users"]
total = response["total"]
else:
response = Users.get_users(skip=skip, limit=limit)
users_list = response["users"]
total = response["total"]
# Convert to SCIM format
scim_users = [user_to_scim(user, request) for user in users_list]
return SCIMListResponse(
totalResults=total,
itemsPerPage=len(scim_users),
startIndex=startIndex,
Resources=scim_users,
)
@router.get("/Users/{user_id}", response_model=SCIMUser)
async def get_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Get SCIM User by ID"""
user = Users.get_user_by_id(user_id)
if not user:
return scim_error(
status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
)
return user_to_scim(user, request)
@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
async def create_user(
request: Request,
user_data: SCIMUserCreateRequest,
_: bool = Depends(get_scim_auth),
):
"""Create SCIM User"""
# Check if user already exists
existing_user = Users.get_user_by_email(user_data.userName)
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {user_data.userName} 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
if user_data.name:
if user_data.name.formatted:
name = user_data.name.formatted
elif user_data.name.givenName or user_data.name.familyName:
name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
# Get profile image if provided
profile_image = "/user.png"
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,
email=email,
profile_image_url=profile_image,
role="user" if user_data.active else "pending",
)
if not new_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user",
)
return user_to_scim(new_user, request)
@router.put("/Users/{user_id}", response_model=SCIMUser)
async def update_user(
user_id: str,
request: Request,
user_data: SCIMUserUpdateRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM User (full update)"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
# Build update dict
update_data = {}
if user_data.userName:
update_data["email"] = user_data.userName
if user_data.displayName:
update_data["name"] = user_data.displayName
elif user_data.name:
if user_data.name.formatted:
update_data["name"] = user_data.name.formatted
elif user_data.name.givenName or user_data.name.familyName:
update_data["name"] = (
f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
)
if user_data.emails and len(user_data.emails) > 0:
update_data["email"] = user_data.emails[0].value
if user_data.active is not None:
update_data["role"] = "user" if user_data.active else "pending"
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)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
return user_to_scim(updated_user, request)
@router.patch("/Users/{user_id}", response_model=SCIMUser)
async def patch_user(
user_id: str,
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM User (partial update)"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
update_data = {}
for operation in patch_data.Operations:
op = operation.op.lower()
path = operation.path
value = operation.value
if op == "replace":
if path == "active":
update_data["role"] = "user" if value else "pending"
elif path == "userName":
update_data["email"] = value
elif path == "displayName":
update_data["name"] = value
elif path == "emails[primary eq true].value":
update_data["email"] = value
elif path == "name.formatted":
update_data["name"] = value
# Update user
if update_data:
updated_user = Users.update_user_by_id(user_id, update_data)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
else:
updated_user = user
return user_to_scim(updated_user, request)
@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Delete SCIM User"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
success = Users.delete_user_by_id(user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete user",
)
return None
# Groups endpoints
@router.get("/Groups", response_model=SCIMListResponse)
async def get_groups(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
):
"""List SCIM Groups"""
# Get all groups
groups_list = Groups.get_groups()
# Apply pagination
total = len(groups_list)
start = startIndex - 1
end = start + count
paginated_groups = groups_list[start:end]
# Convert to SCIM format
scim_groups = [group_to_scim(group, request) for group in paginated_groups]
return SCIMListResponse(
totalResults=total,
itemsPerPage=len(scim_groups),
startIndex=startIndex,
Resources=scim_groups,
)
@router.get("/Groups/{group_id}", response_model=SCIMGroup)
async def get_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Get SCIM Group by ID"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
return group_to_scim(group, request)
@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
async def create_group(
request: Request,
group_data: SCIMGroupCreateRequest,
_: bool = Depends(get_scim_auth),
):
"""Create SCIM Group"""
# Extract member IDs
member_ids = []
if group_data.members:
for member in group_data.members:
member_ids.append(member.value)
# Create group
from open_webui.models.groups import GroupForm
form = GroupForm(
name=group_data.displayName,
description="",
)
# Need to get the creating user's ID - we'll use the first admin
admin_user = Users.get_super_admin_user()
if not admin_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No admin user found",
)
new_group = Groups.insert_new_group(admin_user.id, form)
if not new_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create group",
)
# Add members if provided
if member_ids:
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=new_group.name,
description=new_group.description,
user_ids=member_ids,
)
Groups.update_group_by_id(new_group.id, update_form)
new_group = Groups.get_group_by_id(new_group.id)
return group_to_scim(new_group, request)
@router.put("/Groups/{group_id}", response_model=SCIMGroup)
async def update_group(
group_id: str,
request: Request,
group_data: SCIMGroupUpdateRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM Group (full update)"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
# Build update form
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=group_data.displayName if group_data.displayName else group.name,
description=group.description,
)
# Handle members if provided
if group_data.members is not None:
member_ids = [member.value for member in group_data.members]
update_form.user_ids = member_ids
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
@router.patch("/Groups/{group_id}", response_model=SCIMGroup)
async def patch_group(
group_id: str,
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM Group (partial update)"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=group.name,
description=group.description,
user_ids=group.user_ids.copy() if group.user_ids else [],
)
for operation in patch_data.Operations:
op = operation.op.lower()
path = operation.path
value = operation.value
if op == "replace":
if path == "displayName":
update_form.name = value
elif path == "members":
# Replace all members
update_form.user_ids = [member["value"] for member in value]
elif op == "add":
if path == "members":
# Add members
if isinstance(value, list):
for member in value:
if isinstance(member, dict) and "value" in member:
if member["value"] not in update_form.user_ids:
update_form.user_ids.append(member["value"])
elif op == "remove":
if path and path.startswith("members[value eq"):
# Remove specific member
member_id = path.split('"')[1]
if member_id in update_form.user_ids:
update_form.user_ids.remove(member_id)
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Delete SCIM Group"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
success = Groups.delete_group_by_id(group_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete group",
)
return None
+11 -40
View File
@@ -198,14 +198,7 @@ async def generate_title(
else:
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
content = title_generation_template(
template,
form_data["messages"],
{
"name": user.name,
"location": user.info.get("location") if user.info else None,
},
)
content = title_generation_template(template, form_data["messages"], user)
max_tokens = (
models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000)
@@ -289,14 +282,7 @@ async def generate_follow_ups(
else:
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
content = follow_up_generation_template(
template,
form_data["messages"],
{
"name": user.name,
"location": user.info.get("location") if user.info else None,
},
)
content = follow_up_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -369,9 +355,7 @@ async def generate_chat_tags(
else:
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
content = tags_generation_template(
template, form_data["messages"], {"name": user.name}
)
content = tags_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -437,13 +421,7 @@ async def generate_image_prompt(
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
content = image_prompt_generation_template(
template,
form_data["messages"],
user={
"name": user.name,
},
)
content = image_prompt_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -492,6 +470,10 @@ async def generate_queries(
detail=f"Query generation is disabled",
)
if getattr(request.state, "cached_queries", None):
log.info(f"Reusing cached queries: {request.state.cached_queries}")
return request.state.cached_queries
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
@@ -524,9 +506,7 @@ async def generate_queries(
else:
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
content = query_generation_template(
template, form_data["messages"], {"name": user.name}
)
content = query_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -611,9 +591,7 @@ async def generate_autocompletion(
else:
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
content = autocomplete_generation_template(
template, prompt, messages, type, {"name": user.name}
)
content = autocomplete_generation_template(template, prompt, messages, type, user)
payload = {
"model": task_model_id,
@@ -675,14 +653,7 @@ async def generate_emoji(
template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE
content = emoji_generation_template(
template,
form_data["prompt"],
{
"name": user.name,
"location": user.info.get("location") if user.info else None,
},
)
content = emoji_generation_template(template, form_data["prompt"], user)
payload = {
"model": task_model_id,
+21 -24
View File
@@ -4,7 +4,10 @@ from typing import Optional
import time
import re
import aiohttp
from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.models.tools import (
ToolForm,
@@ -14,15 +17,14 @@ from open_webui.models.tools import (
Tools,
)
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.tools import get_tool_specs
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.env import SRC_LOG_LEVELS
from open_webui.utils.tools import get_tool_servers
from open_webui.utils.tools import get_tool_servers_data
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
@@ -31,6 +33,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
############################
# GetTools
############################
@@ -38,23 +41,14 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
if not request.app.state.TOOL_SERVERS:
# If the tool servers are not set, we need to set them
# This is done only once when the server starts
# This is done to avoid loading the tool servers every time
request.app.state.TOOL_SERVERS = await get_tool_servers_data(
request.app.state.config.TOOL_SERVER_CONNECTIONS
)
tools = Tools.get_tools()
for server in request.app.state.TOOL_SERVERS:
for server in await get_tool_servers(request):
tools.append(
ToolUserResponse(
**{
"id": f"server:{server['idx']}",
"user_id": f"server:{server['idx']}",
"id": f"server:{server.get('id')}",
"user_id": f"server:{server.get('id')}",
"name": server.get("openapi", {})
.get("info", {})
.get("title", "Tool Server"),
@@ -64,7 +58,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
.get("description", ""),
},
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
server["idx"]
server.get("idx", 0)
]
.get("config", {})
.get("access_control", None),
@@ -74,15 +68,18 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
if user.role != "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools
return tools
else:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
tools = [
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control)
or has_access(user.id, "read", tool.access_control, user_group_ids)
]
return tools
return tools
############################
@@ -92,7 +89,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
@router.get("/list", response_model=list[ToolUserResponse])
async def get_tool_list(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "write")
+77 -3
View File
@@ -1,7 +1,17 @@
import logging
from typing import Optional
import base64
import io
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response, StreamingResponse, FileResponse
from pydantic import BaseModel
from open_webui.models.auths import Auths
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
@@ -21,9 +31,8 @@ from open_webui.socket.main import (
get_user_active_status,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions, has_permission
@@ -134,9 +143,15 @@ class SharingPermissions(BaseModel):
class ChatPermissions(BaseModel):
controls: bool = True
valves: bool = True
system_prompt: bool = True
params: bool = True
file_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
regenerate_response: bool = True
rate_response: bool = True
edit: bool = True
share: bool = True
export: bool = True
@@ -327,6 +342,55 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
)
@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict])
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
if sessions and len(sessions) > 0:
return sessions
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserProfileImageById
############################
@router.get("/{user_id}/profile/image")
async 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
if user.profile_image_url.startswith("http"):
return Response(
status_code=status.HTTP_302_FOUND,
headers={"Location": user.profile_image_url},
)
elif user.profile_image_url.startswith("data:image"):
try:
header, base64_data = user.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
return StreamingResponse(
image_buffer,
media_type="image/png",
headers={"Content-Disposition": "inline; filename=image.png"},
)
except Exception as e:
pass
return FileResponse(f"{STATIC_DIR}/user.png")
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserActiveStatusById
############################
@@ -455,3 +519,13 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
############################
# GetUserGroupsById
############################
@router.get("/{user_id}/groups")
async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)):
return Groups.get_groups_by_member_id(user_id)
+53 -8
View File
@@ -22,9 +22,11 @@ from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
WEBSOCKET_REDIS_CLUSTER,
WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
REDIS_KEY_PREFIX,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
@@ -85,6 +87,7 @@ if WEBSOCKET_MANAGER == "redis":
redis_sentinels=get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
),
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
async_mode=True,
)
@@ -92,26 +95,30 @@ if WEBSOCKET_MANAGER == "redis":
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
SESSION_POOL = RedisDict(
"open-webui:session_pool",
f"{REDIS_KEY_PREFIX}:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USER_POOL = RedisDict(
"open-webui:user_pool",
f"{REDIS_KEY_PREFIX}:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USAGE_POOL = RedisDict(
"open-webui:usage_pool",
f"{REDIS_KEY_PREFIX}:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock",
lock_name=f"{REDIS_KEY_PREFIX}:usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
@@ -126,7 +133,7 @@ else:
YDOC_MANAGER = YdocManager(
redis=REDIS,
redis_key_prefix="open-webui:ydoc:documents",
redis_key_prefix=f"{REDIS_KEY_PREFIX}:ydoc:documents",
)
@@ -259,7 +266,9 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
SESSION_POOL[sid] = user.model_dump()
SESSION_POOL[sid] = user.model_dump(
exclude=["date_of_birth", "bio", "gender"]
)
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@@ -281,7 +290,7 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.model_dump()
SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"])
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@@ -581,7 +590,7 @@ async def yjs_document_leave(sid, data):
)
if (
YDOC_MANAGER.document_exists(document_id)
await YDOC_MANAGER.document_exists(document_id)
and len(await YDOC_MANAGER.get_users(document_id)) == 0
):
log.info(f"Cleaning up document {document_id} as no users are left")
@@ -696,6 +705,42 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
files = event_data.get("data", {}).get("files", [])
files.extend(message.get("files", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"files": files,
},
)
if event_data.get("type") in ["source", "citation"]:
data = event_data.get("data", {})
if data.get("type") == None:
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
sources = message.get("sources", [])
sources.append(data)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"sources": sources,
},
)
return __event_emitter__
+20 -5
View File
@@ -1,18 +1,30 @@
import json
import uuid
from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_KEY_PREFIX
from typing import Optional, List, Tuple
import pycrdt as Y
class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
def __init__(
self,
redis_url,
lock_name,
timeout_secs,
redis_sentinels=[],
redis_cluster=False,
):
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=redis_cluster,
decode_responses=True,
)
def aquire_lock(self):
@@ -35,10 +47,13 @@ class RedisLock:
class RedisDict:
def __init__(self, name, redis_url, redis_sentinels=[]):
def __init__(self, name, redis_url, redis_sentinels=[], redis_cluster=False):
self.name = name
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=redis_cluster,
decode_responses=True,
)
def __setitem__(self, key, value):
@@ -97,7 +112,7 @@ class YdocManager:
def __init__(
self,
redis=None,
redis_key_prefix: str = "open-webui:ydoc:documents",
redis_key_prefix: str = f"{REDIS_KEY_PREFIX}:ydoc:documents",
):
self._updates = {}
self._users = {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+3
View File
@@ -112,6 +112,9 @@ class S3StorageProvider(StorageProvider):
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
"addressing_style": S3_ADDRESSING_STYLE,
},
# KIT change - see https://github.com/boto/boto3/issues/4400#issuecomment-2600742103∆
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
)
# If access key and secret are provided, use them for authentication
+6 -6
View File
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
from fastapi import Request
from typing import Dict, List, Optional
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, REDIS_KEY_PREFIX
log = logging.getLogger(__name__)
@@ -19,9 +19,9 @@ tasks: Dict[str, asyncio.Task] = {}
item_tasks = {}
REDIS_TASKS_KEY = "open-webui:tasks"
REDIS_ITEM_TASKS_KEY = "open-webui:tasks:item"
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
REDIS_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks"
REDIS_ITEM_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks:item"
REDIS_PUBSUB_CHANNEL = f"{REDIS_KEY_PREFIX}:tasks:commands"
async def redis_task_command_listener(app):
@@ -153,9 +153,9 @@ async def stop_task(redis, task_id: str):
# Optionally check if task_id still in Redis a few moments later for feedback?
return {"status": True, "message": f"Stop signal sent for {task_id}"}
task = tasks.pop(task_id)
task = tasks.pop(task_id, None)
if not task:
raise ValueError(f"Task with ID {task_id} not found.")
return {"status": False, "message": f"Task with ID {task_id} not found."}
task.cancel() # Request task cancellation
try:
+8 -7
View File
@@ -1,4 +1,4 @@
from typing import Optional, Union, List, Dict, Any
from typing import Optional, Set, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
@@ -60,8 +60,7 @@ def get_permissions(
# Combine permissions from all user groups
for group in user_groups:
group_permissions = group.permissions or {}
permissions = combine_permissions(permissions, group_permissions)
permissions = combine_permissions(permissions, group.permissions or {})
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
@@ -96,8 +95,7 @@ def has_permission(
user_groups = Groups.get_groups_by_member_id(user_id)
for group in user_groups:
group_permissions = group.permissions
if get_permission(group_permissions, permission_hierarchy):
if get_permission(group.permissions or {}, permission_hierarchy):
return True
# Check default permissions afterward if the group permissions don't allow it
@@ -111,12 +109,15 @@ def has_access(
user_id: str,
type: str = "write",
access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
) -> bool:
if access_control is None:
return type == "read"
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = [group.id for group in user_groups]
if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", [])
permitted_user_ids = permission_access.get("user_ids", [])
+1 -1
View File
@@ -195,7 +195,7 @@ class AuditLoggingMiddleware:
try:
user = get_current_user(
request, None, get_http_authorization_cred(auth_header)
request, None, None, get_http_authorization_cred(auth_header)
)
return user
except Exception as e:
+110 -54
View File
@@ -8,6 +8,12 @@ import requests
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import json
from datetime import datetime, timedelta
import pytz
from pytz import UTC
@@ -18,7 +24,11 @@ from opentelemetry import trace
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
OFFLINE_MODE,
LICENSE_BLOB,
pk,
WEBUI_SECRET_KEY,
TRUSTED_SIGNATURE_KEY,
STATIC_DIR,
@@ -74,6 +84,18 @@ def override_static(path: str, content: str):
def get_license_data(app, key):
def data_handler(data):
for k, v in data.items():
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
def handler(u):
res = requests.post(
f"{u}/api/v1/license/",
@@ -83,16 +105,7 @@ def get_license_data(app, key):
if getattr(res, "ok", False):
payload = getattr(res, "json", lambda: {})()
for k, v in payload.items():
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
data_handler(payload)
return True
else:
log.error(
@@ -100,13 +113,44 @@ def get_license_data(app, key):
)
if key:
us = ["https://api.openwebui.com", "https://licenses.api.openwebui.com"]
us = [
"https://api.openwebui.com",
"https://licenses.api.openwebui.com",
]
try:
for u in us:
if handler(u):
return True
except Exception as ex:
log.exception(f"License: Uncaught Exception: {ex}")
try:
if LICENSE_BLOB:
nl = 12
kb = hashlib.sha256((key.replace("-", "").upper()).encode()).digest()
def nt(b):
return b[:nl], b[nl:]
lb = base64.b64decode(LICENSE_BLOB)
ln, lt = nt(lb)
aesgcm = AESGCM(kb)
p = json.loads(aesgcm.decrypt(ln, lt, None))
pk.verify(base64.b64decode(p["s"]), p["p"].encode())
pb = base64.b64decode(p["p"])
pn, pt = nt(pb)
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
if not data.get("exp") and data.get("exp") < datetime.now().date():
return False
data_handler(data)
return True
except Exception as e:
log.error(f"License: {e}")
return False
@@ -177,7 +221,7 @@ def get_current_user(
token = request.cookies.get("token")
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
raise HTTPException(status_code=401, detail="Not authenticated")
# auth by api key
if token.startswith("sk-"):
@@ -217,55 +261,67 @@ def get_current_user(
return user
# auth by jwt token
try:
data = decode_token(token)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
if data is not None and "id" in data:
user = Users.get_user_by_id(data["id"])
if user is None:
try:
try:
data = decode_token(token)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
detail="Invalid token",
)
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
trusted_email = request.headers.get(
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
).lower()
if trusted_email and user.email != trusted_email:
# Delete the token cookie
response.delete_cookie("token")
# Delete OAuth token if present
if request.cookies.get("oauth_id_token"):
response.delete_cookie("oauth_id_token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User mismatch. Please sign in again.",
if data is not None and "id" in data:
user = Users.get_user_by_id(data["id"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
trusted_email = request.headers.get(
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
).lower()
if trusted_email and user.email != trusted_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User mismatch. Please sign in again.",
)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(
Users.update_user_last_active_by_id, user.id
)
return user
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
except Exception as e:
# Delete the token cookie
if request.cookies.get("token"):
response.delete_cookie("token")
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
if request.cookies.get("oauth_id_token"):
response.delete_cookie("oauth_id_token")
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
return user
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Delete OAuth session if present
if request.cookies.get("oauth_session_id"):
response.delete_cookie("oauth_session_id")
raise e
def get_current_user_by_api_key(api_key: str):
+11 -6
View File
@@ -5,8 +5,6 @@ from typing import TYPE_CHECKING
from loguru import logger
from opentelemetry import trace
from open_webui.env import (
AUDIT_UVICORN_LOGGER_NAMES,
AUDIT_LOG_FILE_ROTATION_SIZE,
@@ -14,6 +12,7 @@ from open_webui.env import (
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
)
@@ -30,13 +29,16 @@ def stdout_format(record: "Record") -> str:
Returns:
str: A formatted log string intended for stdout.
"""
record["extra"]["extra_json"] = json.dumps(record["extra"])
if record["extra"]:
record["extra"]["extra_json"] = json.dumps(record["extra"])
extra_format = " - {extra[extra_json]}"
else:
extra_format = ""
return (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level> - {extra[extra_json]}"
"\n{exception}"
"<level>{message}</level>" + extra_format + "\n{exception}"
)
@@ -65,6 +67,10 @@ class InterceptHandler(logging.Handler):
logger.opt(depth=depth, exception=record.exc_info).bind(
**self._get_extras()
).log(level, record.getMessage())
if ENABLE_OTEL and ENABLE_OTEL_LOGS:
from open_webui.utils.telemetry.logs import otel_handler
otel_handler.emit(record)
def _get_extras(self):
if not ENABLE_OTEL:
@@ -126,7 +132,6 @@ def start_logger():
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
)
if AUDIT_LOG_LEVEL != "NONE":
try:
logger.add(
+400 -177
View File
@@ -3,6 +3,7 @@ import logging
import sys
import os
import base64
import textwrap
import asyncio
from aiocache import cached
@@ -19,7 +20,7 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException
from starlette.responses import Response, StreamingResponse
from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.chats import Chats
@@ -73,6 +74,7 @@ from open_webui.utils.misc import (
add_or_update_user_message,
get_last_user_message,
get_last_assistant_message,
get_system_message,
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
)
@@ -83,19 +85,23 @@ from open_webui.utils.filter import (
process_filter_functions,
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body
from open_webui.tasks import create_task
from open_webui.config import (
CACHE_DIR,
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
DEFAULT_CODE_INTERPRETER_PROMPT,
CODE_INTERPRETER_BLOCKED_MODULES,
)
from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
ENABLE_QUERIES_CACHE,
)
from open_webui.constants import TASKS
@@ -105,6 +111,20 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
DEFAULT_REASONING_TAGS = [
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<reason>", "</reason>"),
("<reasoning>", "</reasoning>"),
("<thought>", "</thought>"),
("<Thought>", "</Thought>"),
("<|begin_of_thought|>", "<|end_of_thought|>"),
("◁think▷", "◁/think▷"),
]
DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
@@ -349,7 +369,7 @@ async def chat_web_search_handler(
"type": "status",
"data": {
"action": "web_search",
"description": "Generating search query",
"description": "Searching the web",
"done": False,
},
}
@@ -386,6 +406,9 @@ async def chat_web_search_handler(
except Exception as e:
queries = [response]
if ENABLE_QUERIES_CACHE:
request.state.cached_queries = queries
except Exception as e:
log.exception(e)
queries = [user_message]
@@ -412,8 +435,8 @@ async def chat_web_search_handler(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searching the web",
"action": "web_search_queries_generated",
"queries": queries,
"done": False,
},
}
@@ -464,6 +487,7 @@ async def chat_web_search_handler(
"action": "web_search",
"description": "Searched {{count}} sites",
"urls": results["filenames"],
"items": results.get("items", []),
"done": True,
},
}
@@ -506,7 +530,7 @@ async def chat_image_generation_handler(
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generating an image", "done": False},
"data": {"description": "Creating image", "done": False},
}
)
@@ -558,7 +582,7 @@ async def chat_image_generation_handler(
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generated an image", "done": True},
"data": {"description": "Image created", "done": True},
}
)
@@ -601,8 +625,9 @@ async def chat_image_generation_handler(
async def chat_completion_files_handler(
request: Request, body: dict, user: UserModel
request: Request, body: dict, extra_params: dict, user: UserModel
) -> tuple[dict, dict[str, list]]:
__event_emitter__ = extra_params["__event_emitter__"]
sources = []
if files := body.get("metadata", {}).get("files", None):
@@ -638,6 +663,17 @@ async def chat_completion_files_handler(
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
await __event_emitter__(
{
"type": "status",
"data": {
"action": "queries_generated",
"queries": queries,
"done": False,
},
}
)
try:
# Offload get_sources_from_items to a separate thread
loop = asyncio.get_running_loop()
@@ -674,6 +710,38 @@ async def chat_completion_files_handler(
log.debug(f"rag_contexts:sources: {sources}")
unique_ids = set()
for source in sources or []:
if not source or len(source.keys()) == 0:
continue
documents = source.get("document") or []
metadatas = source.get("metadata") or []
src_info = source.get("source") or {}
for index, _ in enumerate(documents):
metadata = metadatas[index] if index < len(metadatas) else None
_id = (
(metadata or {}).get("source")
or (src_info or {}).get("id")
or "N/A"
)
unique_ids.add(_id)
sources_count = len(unique_ids)
await __event_emitter__(
{
"type": "status",
"data": {
"action": "sources_retrieved",
"count": sources_count,
"done": True,
},
}
)
return body, {"sources": sources}
@@ -683,7 +751,9 @@ def apply_params_to_form_data(form_data, model):
open_webui_params = {
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"reasoning_tags": list,
"system": str,
}
@@ -733,9 +803,27 @@ async def process_chat_payload(request, form_data, user, metadata, model):
form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}")
system_message = get_system_message(form_data.get("messages", []))
if system_message:
try:
form_data = apply_system_prompt_to_body(
system_message.get("content"), form_data, metadata, user
)
except:
pass
event_emitter = get_event_emitter(metadata)
event_call = get_event_call(metadata)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_call,
@@ -743,6 +831,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__metadata__": metadata,
"__request__": request,
"__model__": model,
"__oauth_token__": oauth_token,
}
# Initialize events to store additional event to be sent to the client
@@ -774,8 +863,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if folder and folder.data:
if "system_prompt" in folder.data:
form_data["messages"] = add_or_update_system_message(
folder.data["system_prompt"], form_data["messages"]
form_data = apply_system_prompt_to_body(
folder.data["system_prompt"], form_data, metadata, user
)
if "files" in folder.data:
form_data["files"] = [
@@ -851,7 +940,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params=extra_params,
)
except Exception as e:
raise Exception(f"Error: {e}")
raise Exception(f"{e}")
features = form_data.pop("features", None)
if features:
@@ -905,7 +994,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tools_dict = {}
if tool_ids:
tools_dict = get_tools(
tools_dict = await get_tools(
request,
tool_ids,
user,
@@ -929,7 +1018,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
if tools_dict:
if metadata.get("function_calling") == "native":
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
@@ -947,7 +1036,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.exception(e)
try:
form_data, flags = await chat_completion_files_handler(request, form_data, user)
form_data, flags = await chat_completion_files_handler(
request, form_data, extra_params, user
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
@@ -986,25 +1077,24 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if prompt is None:
raise Exception("No user message found")
if context_string == "":
if request.app.state.config.RELEVANCE_THRESHOLD == 0:
log.debug(
f"With a 0 relevancy threshold for RAG, the context cannot be empty"
)
else:
if context_string != "":
# Workaround for Ollama 2.0+ system prompt issue
# TODO: replace with add_or_update_system_message
if model.get("owned_by") == "ollama":
form_data["messages"] = prepend_to_first_user_message_content(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, prompt
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
)
else:
form_data["messages"] = add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, prompt
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
)
@@ -1251,91 +1341,134 @@ async def process_chat_response(
# Non-streaming response
if not isinstance(response, StreamingResponse):
if event_emitter:
if "error" in response:
error = response["error"].get("detail", response["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
try:
if isinstance(response, dict) or isinstance(response, JSONResponse):
if isinstance(response, list) and len(response) == 1:
# If the response is a single-item list, unwrap it #17213
response = response[0]
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response["selected_model_id"],
},
)
if isinstance(response, JSONResponse) and isinstance(
response.body, bytes
):
try:
response_data = json.loads(response.body.decode("utf-8"))
except json.JSONDecodeError:
response_data = {
"error": {"detail": "Invalid JSON response"}
}
else:
response_data = response
choices = response.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if "error" in response_data:
error = response_data.get("error")
if content:
if isinstance(error, dict):
error = error.get("detail", error)
else:
error = str(error)
await event_emitter(
{
"type": "chat:completion",
"data": response,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
}
)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
)
if isinstance(error, str) or isinstance(error, dict):
await event_emitter(
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
"type": "chat:message:error",
"data": {"error": {"content": error}},
}
)
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
},
)
choices = response_data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response_data["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response_data,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
},
}
)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
await background_tasks_handler()
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
await background_tasks_handler()
response = {
**extra_response,
**response,
}
if events and isinstance(events, list):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response_data = {
**extra_response,
**response_data,
}
if isinstance(response, dict):
response = response_data
if isinstance(response, JSONResponse):
response = JSONResponse(
content=response_data,
headers=response.headers,
status_code=response.status_code,
)
except Exception as e:
log.debug(f"Error occurred while processing request: {e}")
pass
return response
else:
@@ -1361,11 +1494,21 @@ async def process_chat_response(
):
return response
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_caller,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request,
"__model__": model,
}
@@ -1381,14 +1524,6 @@ async def process_chat_response(
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get("model", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"model": model_id,
},
)
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = (
@@ -1410,13 +1545,18 @@ async def process_chat_response(
for block in content_blocks:
if block["type"] == "text":
content = f"{content}{block['content'].strip()}\n"
block_content = block["content"].strip()
if block_content:
content = f"{content}{block_content}\n"
elif block["type"] == "tool_calls":
attributes = block.get("attributes", {})
tool_calls = block.get("content", [])
results = block.get("results", [])
if content and not content.endswith("\n"):
content += "\n"
if results:
tool_calls_display_content = ""
@@ -1438,13 +1578,13 @@ async def process_chat_response(
tool_result_files = result.get("files", None)
break
if tool_result:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
if tool_result is not None:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
if not raw:
content = f"{content}\n{tool_calls_display_content}\n\n"
content = f"{content}{tool_calls_display_content}"
else:
tool_calls_display_content = ""
@@ -1457,10 +1597,10 @@ async def process_chat_response(
"arguments", ""
)
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
if not raw:
content = f"{content}\n{tool_calls_display_content}\n\n"
content = f"{content}{tool_calls_display_content}"
elif block["type"] == "reasoning":
reasoning_display_content = "\n".join(
@@ -1470,16 +1610,26 @@ async def process_chat_response(
reasoning_duration = block.get("duration", None)
start_tag = block.get("start_tag", "")
end_tag = block.get("end_tag", "")
if content and not content.endswith("\n"):
content += "\n"
if reasoning_duration is not None:
if raw:
content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
content = (
f'{content}{start_tag}{block["content"]}{end_tag}\n'
)
else:
content = f'{content}\n<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
content = f'{content}<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
else:
if raw:
content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
content = (
f'{content}{start_tag}{block["content"]}{end_tag}\n'
)
else:
content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
elif block["type"] == "code_interpreter":
attributes = block.get("attributes", {})
@@ -1499,26 +1649,30 @@ async def process_chat_response(
# Keep content as is - either closing backticks or no backticks
content = content_stripped + original_whitespace
if content and not content.endswith("\n"):
content += "\n"
if output:
output = html.escape(json.dumps(output))
if raw:
content = f'{content}\n<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n```output\n{output}\n```\n'
content = f'{content}<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n```output\n{output}\n```\n'
else:
content = f'{content}\n<details type="code_interpreter" done="true" output="{output}">\n<summary>Analyzed</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
content = f'{content}<details type="code_interpreter" done="true" output="{output}">\n<summary>Analyzed</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
else:
if raw:
content = f'{content}\n<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n'
content = f'{content}<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n'
else:
content = f'{content}\n<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
content = f'{content}<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
else:
block_content = str(block["content"]).strip()
content = f"{content}{block['type']}: {block_content}\n"
if block_content:
content = f"{content}{block['type']}: {block_content}\n"
return content.strip()
def convert_content_blocks_to_messages(content_blocks):
def convert_content_blocks_to_messages(content_blocks, raw=False):
messages = []
temp_blocks = []
@@ -1527,7 +1681,7 @@ async def process_chat_response(
messages.append(
{
"role": "assistant",
"content": serialize_content_blocks(temp_blocks),
"content": serialize_content_blocks(temp_blocks, raw),
"tool_calls": block.get("content"),
}
)
@@ -1539,7 +1693,7 @@ async def process_chat_response(
{
"role": "tool",
"tool_call_id": result["tool_call_id"],
"content": result["content"],
"content": result.get("content", "") or "",
}
)
temp_blocks = []
@@ -1547,7 +1701,7 @@ async def process_chat_response(
temp_blocks.append(block)
if temp_blocks:
content = serialize_content_blocks(temp_blocks)
content = serialize_content_blocks(temp_blocks, raw)
if content:
messages.append(
{
@@ -1586,9 +1740,13 @@ async def process_chat_response(
match = re.search(start_tag_pattern, content)
if match:
attr_content = (
match.group(1) if match.group(1) else ""
) # Ensure it's not None
try:
attr_content = (
match.group(1) if match.group(1) else ""
) # Ensure it's not None
except:
attr_content = ""
attributes = extract_attributes(
attr_content
) # Extract attributes safely
@@ -1758,27 +1916,23 @@ async def process_chat_response(
}
]
# We might want to disable this by default
DETECT_REASONING = True
DETECT_SOLUTION = True
reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
DETECT_REASONING_TAGS = reasoning_tags_param is not False
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
"code_interpreter", False
)
reasoning_tags = [
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<reason>", "</reason>"),
("<reasoning>", "</reasoning>"),
("<thought>", "</thought>"),
("<Thought>", "</Thought>"),
("<|begin_of_thought|>", "<|end_of_thought|>"),
("◁think▷", "◁/think▷"),
]
code_interpreter_tags = [("<code_interpreter>", "</code_interpreter>")]
solution_tags = [("<|begin_of_solution|>", "<|end_of_solution|>")]
reasoning_tags = []
if DETECT_REASONING_TAGS:
if (
isinstance(reasoning_tags_param, list)
and len(reasoning_tags_param) == 2
):
reasoning_tags = [
(reasoning_tags_param[0], reasoning_tags_param[1])
]
else:
reasoning_tags = DEFAULT_REASONING_TAGS
try:
for event in events:
@@ -1804,6 +1958,30 @@ async def process_chat_response(
response_tool_calls = []
delta_count = 0
delta_chunk_size = max(
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
int(
metadata.get("params", {}).get("stream_delta_chunk_size")
or 1
),
)
last_delta_data = None
async def flush_pending_delta_data(threshold: int = 0):
nonlocal delta_count
nonlocal last_delta_data
if delta_count >= threshold and last_delta_data:
await event_emitter(
{
"type": "chat:completion",
"data": last_delta_data,
}
)
delta_count = 0
last_delta_data = None
async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line
data = line
@@ -1843,6 +2021,12 @@ async def process_chat_response(
"selectedModelId": model_id,
},
)
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
else:
choices = data.get("choices", [])
if not choices:
@@ -1857,6 +2041,10 @@ async def process_chat_response(
}
)
usage = data.get("usage", {})
usage.update(
data.get("timing", {})
) # llama.cpp
if usage:
await event_emitter(
{
@@ -1943,8 +2131,8 @@ async def process_chat_response(
):
reasoning_block = {
"type": "reasoning",
"start_tag": "think",
"end_tag": "/think",
"start_tag": "<think>",
"end_tag": "</think>",
"attributes": {
"type": "reasoning_content"
},
@@ -2000,7 +2188,7 @@ async def process_chat_response(
content_blocks[-1]["content"] + value
)
if DETECT_REASONING:
if DETECT_REASONING_TAGS:
content, content_blocks, _ = (
tag_content_handler(
"reasoning",
@@ -2010,11 +2198,20 @@ async def process_chat_response(
)
)
content, content_blocks, _ = (
tag_content_handler(
"solution",
DEFAULT_SOLUTION_TAGS,
content,
content_blocks,
)
)
if DETECT_CODE_INTERPRETER:
content, content_blocks, end = (
tag_content_handler(
"code_interpreter",
code_interpreter_tags,
DEFAULT_CODE_INTERPRETER_TAGS,
content,
content_blocks,
)
@@ -2023,16 +2220,6 @@ async def process_chat_response(
if end:
break
if DETECT_SOLUTION:
content, content_blocks, _ = (
tag_content_handler(
"solution",
solution_tags,
content,
content_blocks,
)
)
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
@@ -2051,12 +2238,18 @@ async def process_chat_response(
),
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
if delta:
delta_count += 1
last_delta_data = data
if delta_count >= delta_chunk_size:
await flush_pending_delta_data(delta_chunk_size)
else:
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
except Exception as e:
done = "data: [DONE]" in line
if done:
@@ -2064,6 +2257,7 @@ async def process_chat_response(
else:
log.debug(f"Error: {e}")
continue
await flush_pending_delta_data()
if content_blocks:
# Clean up the last text block
@@ -2083,6 +2277,15 @@ async def process_chat_response(
}
)
if content_blocks[-1]["type"] == "reasoning":
reasoning_block = content_blocks[-1]
if reasoning_block.get("ended_at") is None:
reasoning_block["ended_at"] = time.time()
reasoning_block["duration"] = int(
reasoning_block["ended_at"]
- reasoning_block["started_at"]
)
if response_tool_calls:
tool_calls.append(response_tool_calls)
@@ -2091,10 +2294,13 @@ async def process_chat_response(
await stream_body_handler(response, form_data)
MAX_TOOL_CALL_RETRIES = 10
tool_call_retries = 0
while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
while (
len(tool_calls) > 0
and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES
):
tool_call_retries += 1
response_tool_calls = tool_calls.pop(0)
@@ -2212,7 +2418,7 @@ async def process_chat_response(
results.append(
{
"tool_call_id": tool_call_id,
"content": tool_result,
"content": tool_result or "",
**(
{"files": tool_result_files}
if tool_result_files
@@ -2246,7 +2452,9 @@ async def process_chat_response(
"tools": form_data["tools"],
"messages": [
*form_data["messages"],
*convert_content_blocks_to_messages(content_blocks),
*convert_content_blocks_to_messages(
content_blocks, True
),
],
}
@@ -2289,6 +2497,27 @@ async def process_chat_response(
try:
if content_blocks[-1]["attributes"].get("type") == "code":
code = content_blocks[-1]["content"]
if CODE_INTERPRETER_BLOCKED_MODULES:
blocking_code = textwrap.dedent(
f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
_real_import = builtins.__import__
def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
if name.split('.')[0] in BLOCKED_MODULES:
importer_name = globals.get('__name__') if globals else None
if importer_name == '__main__':
raise ImportError(
f"Direct import of module {{name}} is restricted."
)
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
"""
)
code = blocking_code + "\n" + code
if (
request.app.state.config.CODE_INTERPRETER_ENGINE
@@ -2454,7 +2683,7 @@ async def process_chat_response(
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
@@ -2476,7 +2705,7 @@ async def process_chat_response(
await background_tasks_handler()
except asyncio.CancelledError:
log.warning("Task was cancelled!")
await event_emitter({"type": "task-cancelled"})
await event_emitter({"type": "chat:tasks:cancel"})
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
@@ -2491,13 +2720,7 @@ async def process_chat_response(
if response.background is not None:
await response.background()
# background_tasks.add_task(response_handler, response, events)
task_id, _ = await create_task(
request.app.state.redis,
response_handler(response, events),
id=metadata["chat_id"],
)
return {"status": True, "task_id": task_id}
return await response_handler(response, events)
else:
# Fallback to the original response
+45 -1
View File
@@ -1,5 +1,6 @@
import hashlib
import re
import threading
import time
import uuid
import logging
@@ -227,7 +228,7 @@ def openai_chat_chunk_message_template(
if tool_calls:
template["choices"][0]["delta"]["tool_calls"] = tool_calls
if not content and not tool_calls:
if not content and not reasoning_content and not tool_calls:
template["choices"][0]["finish_reason"] = "stop"
if usage:
@@ -478,3 +479,46 @@ def convert_logit_bias_input_to_json(user_input):
bias = 100 if bias > 100 else -100 if bias < -100 else bias
logit_bias_json[token] = bias
return json.dumps(logit_bias_json)
def freeze(value):
"""
Freeze a value to make it hashable.
"""
if isinstance(value, dict):
return frozenset((k, freeze(v)) for k, v in value.items())
elif isinstance(value, list):
return tuple(freeze(v) for v in value)
return value
def throttle(interval: float = 10.0):
"""
Decorator to prevent a function from being called more than once within a specified duration.
If the function is called again within the duration, it returns None. To avoid returning
different types, the return type of the function should be Optional[T].
:param interval: Duration in seconds to wait before allowing the function to be called again.
"""
def decorator(func):
last_calls = {}
lock = threading.Lock()
def wrapper(*args, **kwargs):
if interval is None:
return func(*args, **kwargs)
key = (args, freeze(kwargs))
now = time.time()
if now - last_calls.get(key, 0) < interval:
return None
with lock:
if now - last_calls.get(key, 0) < interval:
return None
last_calls[key] = now
return func(*args, **kwargs)
return wrapper
return decorator
+494 -186
View File
@@ -4,6 +4,11 @@ import mimetypes
import sys
import uuid
import json
from datetime import datetime, timedelta
import re
import fnmatch
import time
import aiohttp
from authlib.integrations.starlette_client import OAuth
@@ -14,8 +19,12 @@ from fastapi import (
)
from starlette.responses import RedirectResponse
from open_webui.models.auths import Auths
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
from open_webui.config import (
DEFAULT_USER_ROLE,
@@ -27,6 +36,7 @@ from open_webui.config import (
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_BLOCKED_GROUPS,
OAUTH_ROLES_CLAIM,
OAUTH_SUB_CLAIM,
OAUTH_GROUPS_CLAIM,
OAUTH_EMAIL_CLAIM,
OAUTH_PICTURE_CLAIM,
@@ -45,6 +55,7 @@ from open_webui.env import (
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@@ -65,6 +76,7 @@ auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMEN
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
@@ -77,22 +89,243 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
"""
Check if a group name matches any blocked pattern.
Supports exact matches, shell-style wildcards (*, ?), and regex patterns.
Args:
group_name: The group name to check
groups: List of patterns to match against
Returns:
True if the group is blocked, False otherwise
"""
if not groups:
return False
for group_pattern in groups:
if not group_pattern: # Skip empty patterns
continue
# Exact match
if group_name == group_pattern:
return True
# Try as regex pattern first if it contains regex-specific characters
if any(
char in group_pattern
for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"]
):
try:
# Use the original pattern as-is for regex matching
if re.search(group_pattern, group_name):
return True
except re.error:
# If regex is invalid, fall through to wildcard check
pass
# Shell-style wildcard match (supports * and ?)
if "*" in group_pattern or "?" in group_pattern:
if fnmatch.fnmatch(group_name, group_pattern):
return True
return False
class OAuthManager:
def __init__(self, app):
self.oauth = OAuth()
self.app = app
self._clients = {}
for _, provider_config in OAUTH_PROVIDERS.items():
provider_config["register"](self.oauth)
def get_client(self, provider_name):
return self.oauth.create_client(provider_name)
if provider_name not in self._clients:
self._clients[provider_name] = self.oauth.create_client(provider_name)
return self._clients[provider_name]
def get_server_metadata_url(self, provider_name):
if provider_name in self._clients:
client = self._clients[provider_name]
return (
client.server_metadata_url
if hasattr(client, "server_metadata_url")
else None
)
return None
def get_oauth_token(
self, user_id: str, session_id: str, force_refresh: bool = False
):
"""
Get a valid OAuth token for the user, automatically refreshing if needed.
Args:
user_id: The user ID
provider: Optional provider name. If None, gets the most recent session.
force_refresh: Force token refresh even if current token appears valid
Returns:
dict: OAuth token data with access_token, or None if no valid token available
"""
try:
# Get the OAuth session
session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
if not session:
log.warning(
f"No OAuth session found for user {user_id}, session {session_id}"
)
return None
if force_refresh or datetime.now() + timedelta(
minutes=5
) >= datetime.fromtimestamp(session.expires_at):
log.debug(
f"Token refresh needed for user {user_id}, provider {session.provider}"
)
refreshed_token = self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, provider {session.provider}"
)
return None
return session.token
except Exception as e:
log.error(f"Error getting OAuth token for user {user_id}: {e}")
return None
async def _refresh_token(self, session) -> dict:
"""
Refresh an OAuth token if needed, with concurrency protection.
Args:
session: The OAuth session object
Returns:
dict: Refreshed token data, or None if refresh failed
"""
try:
# Perform the actual refresh
refreshed_token = await self._perform_token_refresh(session)
if refreshed_token:
# Update the session with new token data
session = OAuthSessions.update_session_by_id(
session.id, refreshed_token
)
log.info(f"Successfully refreshed token for session {session.id}")
return session.token
else:
log.error(f"Failed to refresh token for session {session.id}")
return None
except Exception as e:
log.error(f"Error refreshing token for session {session.id}: {e}")
return None
async def _perform_token_refresh(self, session) -> dict:
"""
Perform the actual OAuth token refresh.
Args:
session: The OAuth session object
Returns:
dict: New token data, or None if refresh failed
"""
provider = session.provider
token_data = session.token
if not token_data.get("refresh_token"):
log.warning(f"No refresh token available for session {session.id}")
return None
try:
client = self.get_client(provider)
if not client:
log.error(f"No OAuth client found for provider {provider}")
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(client.gserver_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
else:
log.error(
f"Failed to fetch OpenID configuration for provider {provider}"
)
if not token_endpoint:
log.error(f"No token endpoint found for provider {provider}")
return None
# Prepare refresh request
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": token_data["refresh_token"],
"client_id": client.client_id,
}
# Add client_secret if available (some providers require it)
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
token_endpoint,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status == 200:
new_token_data = await r.json()
# Merge with existing token data (preserve refresh_token if not provided)
if "refresh_token" not in new_token_data:
new_token_data["refresh_token"] = token_data[
"refresh_token"
]
# Add timestamp for tracking
new_token_data["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if (
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = (
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
log.debug(f"Token refresh successful for provider {provider}")
return new_token_data
else:
error_text = await r.text()
log.error(
f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
)
return None
except Exception as e:
log.error(f"Exception during token refresh for provider {provider}: {e}")
return None
def get_user_role(self, user, user_data):
if user and Users.get_num_users() == 1:
user_count = Users.get_num_users()
if user and user_count == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
log.debug("Assigning the only user the admin role")
return "admin"
if not user and Users.get_num_users() == 0:
if not user and user_count == 0:
# If there are no users, assign the role "admin", as the first user will be an admin
log.debug("Assigning the first user the admin role")
return "admin"
@@ -112,7 +345,13 @@ class OAuthManager:
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
oauth_roles = claim_data if isinstance(claim_data, list) else []
oauth_roles = []
if isinstance(claim_data, list):
oauth_roles = claim_data
if isinstance(claim_data, str) or isinstance(claim_data, int):
oauth_roles = [str(claim_data)]
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
@@ -229,7 +468,7 @@ class OAuthManager:
if (
user_oauth_groups
and group_model.name not in user_oauth_groups
and group_model.name not in blocked_groups
and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Remove group from user
log.debug(
@@ -260,7 +499,7 @@ class OAuthManager:
user_oauth_groups
and group_model.name in user_oauth_groups
and not any(gm.name == group_model.name for gm in user_current_groups)
and group_model.name not in blocked_groups
and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Add user to group
log.debug(
@@ -345,201 +584,270 @@ class OAuthManager:
async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
client = self.get_client(provider)
try:
token = await client.authorize_access_token(request)
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
user_data: UserInfo = token.get("userinfo")
if not user_data or auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data:
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}"
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided
if not email:
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
if provider == "github":
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
emails = await resp.json()
# use the primary email as the user's email
primary_email = next(
(e["email"] for e in emails if e.get("primary")),
None,
)
if primary_email:
email = primary_email
else:
log.warning(
"No primary email found in GitHub response"
error_message = None
try:
client = self.get_client(provider)
try:
token = await client.authorize_access_token(request)
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Try to get userinfo from the token first, some providers include it there
user_data: UserInfo = token.get("userinfo")
if (
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Extract the "sub" claim, using custom claim if configured
if auth_manager_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}"
# Email extraction
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided
if not email:
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
if provider == "github":
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
emails = await resp.json()
# use the primary email as the user's email
primary_email = next(
(
e["email"]
for e in emails
if e.get("primary")
),
None,
)
if primary_email:
email = primary_email
else:
log.warning(
"No primary email found in GitHub response"
)
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
else:
log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
else:
log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
if not user:
user_count = Users.get_num_users()
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
picture_url = "/user.png"
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
log.warning("Username claim is missing, using email as name")
name = email
role = self.get_user_role(None, user_data)
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=role,
oauth_sub=provider_sub,
# If allowed domains are configured, check if the email domain is in the list
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1]
not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if auth_manager_config.WEBHOOK_URL:
post_webhook(
WEBUI_NAME,
auth_manager_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),
},
)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
else:
picture_url = "/user.png"
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
log.warning("Username claim is missing, using email as name")
name = email
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth_sub=provider_sub,
)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_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),
},
)
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
jwt_token = create_token(
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if (
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
and user.role != "admin"
):
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
)
jwt_token = create_token(
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
except Exception as e:
log.error(f"Error during OAuth process: {e}")
error_message = (
e.detail
if isinstance(e, HTTPException) and e.detail
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
)
# Set the cookie token
response.set_cookie(
key="token",
value=jwt_token,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = token.get("id_token")
response.set_cookie(
key="oauth_id_token",
value=oauth_id_token,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
# Redirect back to the frontend with the JWT token
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
if redirect_base_url.endswith("/"):
redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth#token={jwt_token}"
redirect_url = f"{redirect_base_url}/auth"
return RedirectResponse(url=redirect_url, headers=response.headers)
if error_message:
redirect_url = f"{redirect_url}?error={error_message}"
return RedirectResponse(url=redirect_url, headers=response.headers)
response = RedirectResponse(url=redirect_url, headers=response.headers)
# Set the cookie token
# Redirect back to the frontend with the JWT token
response.set_cookie(
key="token",
value=jwt_token,
httponly=False, # Required for frontend access
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
# Legacy cookies for compatibility with older frontend versions
if ENABLE_OAUTH_ID_TOKEN_COOKIE:
response.set_cookie(
key="oauth_id_token",
value=token.get("id_token"),
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
try:
# Add timestamp for tracking
token["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
sessions = OAuthSessions.get_sessions_by_user_id(user.id)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id)
session = OAuthSessions.create_session(
user_id=user.id,
provider=provider,
token=token,
)
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
except Exception as e:
log.error(f"Failed to store OAuth session server-side: {e}")
return response
+4 -10
View File
@@ -9,7 +9,7 @@ import json
# inplace function: form_data is modified
def apply_model_system_prompt_to_body(
def apply_system_prompt_to_body(
system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None
) -> dict:
if not system:
@@ -22,15 +22,7 @@ def apply_model_system_prompt_to_body(
system = prompt_variables_template(system, variables)
# Legacy (API Usage)
if user:
template_params = {
"user_name": user.name,
"user_location": user.info.get("location") if user.info else None,
}
else:
template_params = {}
system = prompt_template(system, **template_params)
system = prompt_template(system, user)
form_data["messages"] = add_or_update_system_message(
system, form_data.get("messages", [])
@@ -69,7 +61,9 @@ def remove_open_webui_params(params: dict) -> dict:
"""
open_webui_params = {
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"reasoning_tags": list,
"system": str,
}
+1 -1
View File
@@ -260,7 +260,7 @@ def install_tool_and_function_dependencies():
all_dependencies += f"{dependencies}, "
for tool in tool_list:
# Only install requirements for admin tools
if tool.user.role == "admin":
if tool.user and tool.user.role == "admin":
frontmatter = extract_frontmatter(replace_imports(tool.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
+44 -11
View File
@@ -10,6 +10,9 @@ from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT
log = logging.getLogger(__name__)
_CONNECTION_CACHE = {}
class SentinelRedisProxy:
def __init__(self, sentinel, service, *, async_mode: bool = True, **kw):
self._sentinel = sentinel
@@ -93,8 +96,8 @@ class SentinelRedisProxy:
def parse_redis_service_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis":
raise ValueError("Invalid Redis URL scheme. Must be 'redis'.")
if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss":
raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.")
return {
"username": parsed_url.username or None,
@@ -106,8 +109,25 @@ def parse_redis_service_url(redis_url):
def get_redis_connection(
redis_url, redis_sentinels, async_mode=False, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=False,
async_mode=False,
decode_responses=True,
):
cache_key = (
redis_url,
tuple(redis_sentinels) if redis_sentinels else (),
async_mode,
decode_responses,
)
if cache_key in _CONNECTION_CACHE:
return _CONNECTION_CACHE[cache_key]
connection = None
if async_mode:
import redis.asyncio as redis
@@ -122,15 +142,19 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
return SentinelRedisProxy(
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
elif redis_url:
return redis.from_url(redis_url, decode_responses=decode_responses)
else:
return None
connection = redis.from_url(redis_url, decode_responses=decode_responses)
else:
import redis
@@ -144,15 +168,24 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
return SentinelRedisProxy(
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
elif redis_url:
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
else:
return None
connection = redis.Redis.from_url(
redis_url, decode_responses=decode_responses
)
_CONNECTION_CACHE[cache_key] = connection
return connection
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
+70 -80
View File
@@ -2,7 +2,7 @@ import logging
import math
import re
from datetime import datetime
from typing import Optional
from typing import Optional, Any
import uuid
@@ -38,9 +38,46 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
return template
def prompt_template(
template: str, user_name: Optional[str] = None, user_location: Optional[str] = None
) -> str:
def prompt_template(template: str, user: Optional[Any] = None) -> str:
USER_VARIABLES = {}
if user:
if hasattr(user, "model_dump"):
user = user.model_dump()
if isinstance(user, dict):
user_info = user.get("info", {}) or {}
birth_date = user.get("date_of_birth")
age = None
if birth_date:
try:
# If birth_date is str, convert to datetime
if isinstance(birth_date, str):
birth_date = datetime.strptime(birth_date, "%Y-%m-%d")
today = datetime.now()
age = (
today.year
- birth_date.year
- (
(today.month, today.day)
< (birth_date.month, birth_date.day)
)
)
except Exception as e:
pass
USER_VARIABLES = {
"name": str(user.get("name")),
"location": str(user_info.get("location")),
"bio": str(user.get("bio")),
"gender": str(user.get("gender")),
"birth_date": str(birth_date),
"age": str(age),
}
# Get the current date
current_date = datetime.now()
@@ -56,19 +93,20 @@ def prompt_template(
)
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
if user_name:
# Replace {{USER_NAME}} in the template with the user's name
template = template.replace("{{USER_NAME}}", user_name)
else:
# Replace {{USER_NAME}} in the template with "Unknown"
template = template.replace("{{USER_NAME}}", "Unknown")
if user_location:
# Replace {{USER_LOCATION}} in the template with the current location
template = template.replace("{{USER_LOCATION}}", user_location)
else:
# Replace {{USER_LOCATION}} in the template with "Unknown"
template = template.replace("{{USER_LOCATION}}", "Unknown")
template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
template = template.replace(
"{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")
)
template = template.replace(
"{{USER_BIRTH_DATE}}", USER_VARIABLES.get("birth_date", "Unknown")
)
template = template.replace(
"{{USER_AGE}}", str(USER_VARIABLES.get("age", "Unknown"))
)
template = template.replace(
"{{USER_LOCATION}}", USER_VARIABLES.get("location", "Unknown")
)
return template
@@ -189,90 +227,56 @@ def rag_template(template: str, context: str, query: str):
def title_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
def follow_up_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
def tags_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
def image_prompt_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
def emoji_generation_template(
template: str, prompt: str, user: Optional[dict] = None
template: str, prompt: str, user: Optional[Any] = None
) -> str:
template = replace_prompt_variable(template, prompt)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
@@ -282,38 +286,24 @@ def autocomplete_generation_template(
prompt: str,
messages: Optional[list[dict]] = None,
type: Optional[str] = None,
user: Optional[dict] = None,
user: Optional[Any] = None,
) -> str:
template = template.replace("{{TYPE}}", type if type else "")
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
def query_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
template = prompt_template(template, user)
return template
@@ -1,31 +0,0 @@
import threading
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import BatchSpanProcessor
class LazyBatchSpanProcessor(BatchSpanProcessor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.done = True
with self.condition:
self.condition.notify_all()
self.worker_thread.join()
self.done = False
self.worker_thread = None
def on_end(self, span: ReadableSpan) -> None:
if self.worker_thread is None:
self.worker_thread = threading.Thread(
name=self.__class__.__name__, target=self.worker, daemon=True
)
self.worker_thread.start()
super().on_end(span)
def shutdown(self) -> None:
self.done = True
with self.condition:
self.condition.notify_all()
if self.worker_thread:
self.worker_thread.join()
self.span_exporter.shutdown()
@@ -0,0 +1,53 @@
import logging
from base64 import b64encode
from opentelemetry.sdk._logs import (
LoggingHandler,
LoggerProvider,
)
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter as HttpOTLPLogExporter,
)
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from open_webui.env import (
OTEL_SERVICE_NAME,
OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
OTEL_LOGS_EXPORTER_OTLP_INSECURE,
OTEL_LOGS_BASIC_AUTH_USERNAME,
OTEL_LOGS_BASIC_AUTH_PASSWORD,
OTEL_LOGS_OTLP_SPAN_EXPORTER,
)
def setup_logging():
headers = []
if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD:
auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}"
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http":
exporter = HttpOTLPLogExporter(
endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
headers=headers,
)
else:
exporter = OTLPLogExporter(
endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_LOGS_EXPORTER_OTLP_INSECURE,
headers=headers,
)
logger_provider = LoggerProvider(resource=resource)
set_logger_provider(logger_provider)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
otel_handler = LoggingHandler(logger_provider=logger_provider)
return otel_handler
otel_handler = setup_logging()

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