Compare commits

...
711 Commits
Author SHA1 Message Date
Timothy Jaeryang Baek 04799f1f95 Merge pull request #11728 from open-webui/dev
0.6
2025-03-31 18:47:18 -07:00
Timothy Jaeryang Baek 1b7c125f00 refac 2025-03-31 18:47:08 -07:00
Timothy Jaeryang Baek 13f7a4cf56 doc: wording 2025-03-31 18:43:27 -07:00
Timothy Jaeryang Baek 8f8d8ba27d doc: changelog 2025-03-31 18:40:49 -07:00
Timothy Jaeryang Baek dca68871e6 doc: wording 2025-03-31 18:39:30 -07:00
Timothy Jaeryang Baek da561c50d0 doc: wording 2025-03-31 18:36:08 -07:00
Timothy Jaeryang Baek 3662ecdeab doc: changelog 2025-03-31 18:34:42 -07:00
Timothy Jaeryang Baek 391dd33da3 chore: format 2025-03-31 17:59:21 -07:00
Timothy Jaeryang Baek 0bc5441d72 refac: styling 2025-03-31 17:58:48 -07:00
Timothy Jaeryang Baek 5f792d2771 feat: enforced temporary chat 2025-03-31 17:58:43 -07:00
Timothy Jaeryang BaekandTaylor Wilsdon 580965df17 feat: public sharing permissions
Co-Authored-By: Taylor Wilsdon <6508528+taylorwilsdon@users.noreply.github.com>
2025-03-31 17:28:25 -07:00
Timothy Jaeryang Baek 50b3f47f81 feat: public sharing permissions 2025-03-31 17:15:51 -07:00
Timothy Jaeryang Baek 3ba12e7a43 Merge pull request #12239 from Phlogi/dev-threads-on-hybrid
perf: parallelize hybrid search
2025-03-31 17:06:32 -07:00
Timothy Jaeryang Baek def5444ea3 feat: hide base model 2025-03-31 16:51:42 -07:00
Timothy Jaeryang Baek cafc5413f5 refac 2025-03-31 14:13:27 -07:00
Timothy Jaeryang Baek 3dc40030a1 Merge pull request #12236 from Phlogi/fix_prefix
Fix middleware.py missing prefix on embedding function
2025-03-31 14:02:49 -07:00
Timothy Jaeryang Baek efb63fb13f chore: format 2025-03-31 14:02:02 -07:00
Timothy Jaeryang Baek 598222b76f Merge pull request #12238 from rgaricano/dev
PR: Translation (es-ES) - Spanish v0.6.0
2025-03-31 14:01:27 -07:00
Timothy Jaeryang Baek fac848ad33 refac: rm log info 2025-03-31 14:00:40 -07:00
_00_ 098c794de3 Update translation.json
fix X typo
lines 4 & 304
2025-03-31 19:52:15 +02:00
_00_ 1571e3dc16 FIX: Translation (es-ES) - Spanish
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.
2025-03-31 17:12:55 +02:00
_00_ a65c426e1d FIX: Translation (es-ES) - Spanish rev.
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.

(added two lost recently updated entries)
2025-03-31 16:59:05 +02:00
Phlogi 9c64310db5 Run hybrid_search in parallel 2025-03-31 16:43:37 +02:00
_00_ 3c507e829f FIX: Translation (es-ES) - Spanish
Complete Revision of Spanish Translation (es-ES)

Revised spanish translation.
Standardization of terminology, vocabulary, and syntax.
Revision of incorrectly explained or defined terms.
2025-03-31 16:42:56 +02:00
Phlogi bc64b2a67f Fix middleware.py missing prefix on embedding function 2025-03-31 15:19:07 +02:00
Timothy Jaeryang Baek 6ac153c989 refac: styling 2025-03-31 03:45:36 -07:00
Timothy Jaeryang Baek 5ce6c8ced3 refac 2025-03-31 01:45:22 -07:00
Timothy Jaeryang Baek 9809ffb095 refac 2025-03-31 01:30:08 -07:00
Timothy Jaeryang Baek a5dbd2e8dd refac: knowledge file ac 2025-03-31 01:10:18 -07:00
Timothy Jaeryang Baek 021e25ade1 Merge pull request #11087 from tarmst/fix-files-access-control
fix: Add access control usage to files APIs
2025-03-31 01:05:08 -07:00
Timothy Jaeryang Baek 05dfb9cecd refac: floating buttons behaviour in shared chat 2025-03-31 00:57:16 -07:00
Timothy Jaeryang Baek effa87638d refac: prompt command behaviour 2025-03-31 00:48:09 -07:00
Timothy Jaeryang Baek 300b7dfcc0 fix: model import/export 2025-03-31 00:39:20 -07:00
Timothy Jaeryang Baek fb875c574c Merge pull request #12218 from aleixdorca/dev
Update catalan translation.json
2025-03-31 00:19:28 -07:00
Aleix Dorca 6e190bebe8 Update catalan translation.json 2025-03-31 09:12:48 +02:00
Timothy Jaeryang Baek e4cc9f2b4f refac: folders 2025-03-30 23:36:15 -07:00
Timothy Jaeryang Baek 337df80c47 refac: styling 2025-03-30 23:17:06 -07:00
Timothy Jaeryang Baek d542881ee4 refac 2025-03-30 21:55:20 -07:00
Timothy Jaeryang Baek 4b75966401 refac: embedding prefix var naming 2025-03-30 21:55:15 -07:00
Timothy Jaeryang Baek 433b5bddc1 Merge pull request #8594 from jayteaftw/main
feat: Support for instruct/prefixing embeddings
2025-03-30 21:54:44 -07:00
Timothy Jaeryang Baek 12101fd26d Merge pull request #11307 from wangtunan/fix/stop_task_func_need_use_async_await_syntax
fix: stopTask func need use async/await syntax
2025-03-30 21:45:32 -07:00
Timothy Jaeryang Baek 33f93371dc feat: user webhooks system settings 2025-03-30 21:31:16 -07:00
Timothy Jaeryang Baek d55735dc1e refac: rm profile image from feedback user object 2025-03-30 21:23:42 -07:00
Timothy Jaeryang Baek d41844c308 feat: prompt autocompletion user setting 2025-03-30 21:19:09 -07:00
Timothy Jaeryang Baek 50b8dec3ac fix/refac: hybrid search 2025-03-30 20:48:22 -07:00
Timothy Jaeryang Baek ce0d82b55f Merge pull request #12132 from Phlogi/dev-fetch-documents-once
Avoid multiple data fetching
2025-03-30 20:44:32 -07:00
Timothy Jaeryang Baek febc85361f Merge pull request #12191 from rgaricano/dev
Fix alignament buttons text
2025-03-30 20:42:21 -07:00
Timothy Jaeryang Baek d9bbdd8dca revert 2025-03-30 20:38:47 -07:00
Timothy Jaeryang Baek 9c00f3caf3 Merge pull request #12192 from Ithanil/avatar_compression
feat: adjust jpeg compression quality to 0.15 for avatar uploads
2025-03-30 20:35:31 -07:00
Timothy Jaeryang Baek ee9ff96c7d Merge pull request #12194 from TiancongLx/dev
i18n: update zh-TW
2025-03-30 17:19:23 -07:00
Tiancong Li 50a0751418 i18n: update zh-TW 2025-03-30 23:24:27 +08:00
Jan Kessler 7f5dc22464 adjust jpeg compression quality to 0.15 for avatar uploads 2025-03-30 14:14:14 +02:00
_00_ 5b8317270e Fix alignament buttons text
Fix alignament buttons text

Fixed align tag on user & admin settings

- correction of some align tag assigned to buttons that are right and
have to be left, this means that although it is not noticeable in
English, in other languages, especially if several lines appear, it is
obvious.
2025-03-30 13:36:07 +02:00
Timothy Jaeryang Baek b5909ffe97 enh: tool args parse json fallback 2025-03-30 02:39:23 -07:00
Timothy Jaeryang Baek 03e61e7a4a refac: styling 2025-03-30 00:47:45 -07:00
Timothy Jaeryang Baek 215f991326 refac 2025-03-30 00:44:09 -07:00
Timothy Jaeryang Baek 2cee5a4a03 chore: format 2025-03-30 00:21:00 -07:00
Timothy Jaeryang Baek 3be626bef3 feat: goto message 2025-03-29 17:48:57 -07:00
Timothy Jaeryang Baek c700126c17 refac: html rendering 2025-03-29 16:52:49 -07:00
Timothy Jaeryang Baek feaa01dc77 fix: ollama requests 2025-03-29 16:52:42 -07:00
Timothy Jaeryang Baek 34607736c6 Merge pull request #12170 from JunaidPinjari/fix/csv-autodetect-encoding
Fix: CSV loader encoding issue using autodetect_encoding=True
2025-03-29 15:23:55 -07:00
Timothy Jaeryang Baek 5b9b802793 refac 2025-03-29 15:23:02 -07:00
Timothy Jaeryang Baek da5ea7acb8 Merge pull request #12176 from JunaidPinjari/feat/file-upload-download-options
Support skipping ingestion and downloading files as attachments
2025-03-29 15:20:47 -07:00
Timothy Jaeryang Baek 08ff1d8d88 refac 2025-03-29 15:03:10 -07:00
Timothy Jaeryang Baek c2ff73c3e1 refac 2025-03-29 14:59:03 -07:00
Junaid Pinjari a805e033a5 Feat: Add optional ingest_file and as_attachment params to upload and download endpoints 2025-03-29 15:42:11 +05:30
Junaid Pinjari e782e7d3a7 Fix: CSV loader encoding issue using autodetect_encoding=True 2025-03-29 13:14:53 +05:30
Timothy Jaeryang Baek 66015bb341 Merge pull request #12168 from yak1ex/fix_inconsistent_value_check_in_onMessageHandler_in_Chat_svelte
fix: Inconsistent value check in onMessageHandler() in Chat.svelte
2025-03-28 22:12:57 -07:00
Timothy Jaeryang Baek 737f41dd2e refac 2025-03-28 13:18:44 -07:00
Timothy Jaeryang Baek 402d32ccfd refac 2025-03-28 13:17:43 -07:00
Timothy Jaeryang Baek 0413c747a9 refac: hide hybrid option with full context mode 2025-03-28 13:16:56 -07:00
Timothy Jaeryang Baek acd5b07fb4 enh: tool servers info modal 2025-03-28 12:40:56 -07:00
Timothy Jaeryang Baek 0a49c366f0 refac 2025-03-28 12:25:59 -07:00
Timothy Jaeryang Baek af2c5f3a18 refac 2025-03-28 12:23:25 -07:00
Timothy Jaeryang Baek 115f34069a refac: tool call display 2025-03-28 12:18:27 -07:00
Timothy Jaeryang Baek 1ac87c55ff chore: format 2025-03-28 11:47:14 -07:00
Timothy Jaeryang Baek b8c1bb0ec5 refac 2025-03-28 02:30:13 -07:00
Timothy Jaeryang Baek f8835d78d9 refac: tool call block display 2025-03-28 02:27:40 -07:00
Timothy Jaeryang Baek fd9641dcd1 refac 2025-03-28 01:20:45 -07:00
Timothy Jaeryang Baek 5656f030c4 refac: error handling 2025-03-28 00:52:13 -07:00
Timothy Jaeryang Baek 2fc8ace46d fix: error message persistence issue 2025-03-28 00:25:00 -07:00
Timothy Jaeryang Baek 2070d1096b refac 2025-03-28 00:07:00 -07:00
Timothy Jaeryang Baek 5f9679253e refac: openapi tool server support 2025-03-27 23:59:16 -07:00
Timothy Jaeryang Baek 359e6f2fd2 Merge pull request #12149 from TiancongLx/dev
i18n: update zh-TW
2025-03-27 23:49:56 -07:00
Tiancong Li f647e86346 i18n: update zh-TW 2025-03-28 14:38:43 +08:00
Timothy Jaeryang Baek 7fce3e2a9c Merge pull request #11148 from Ithanil/redis_sentinel
feat: Support for Redis Sentinel
2025-03-27 20:36:53 -07:00
Timothy Jaeryang Baek c137d3ad17 enh: allow json schema for format 2025-03-27 20:27:16 -07:00
Timothy Jaeryang Baek 424f007b10 fix: ldap certificate path should not be required 2025-03-27 19:55:41 -07:00
Timothy Jaeryang Baek 5e720cd84b refac: ollama response_format support 2025-03-27 19:26:08 -07:00
Timothy Jaeryang Baek 7845b2fbef Merge pull request #12122 from panda44312/patch-8
i18n: zh-cn
2025-03-27 14:48:45 -07:00
Phlogi 04bf9ddab2 Avoid multiple data fetching 2025-03-27 19:05:20 +01:00
Panda 72bef8798f i18n: zh-cn 2025-03-27 11:05:57 +01:00
Timothy Jaeryang Baek 4547453141 refac 2025-03-27 02:57:02 -07:00
Timothy Jaeryang Baek fc6d432c2f refac 2025-03-27 02:53:24 -07:00
Timothy Jaeryang Baek 038df1131e refac 2025-03-27 02:50:53 -07:00
Timothy Jaeryang Baek d1bc2cfa2f refac: external tools server support 2025-03-27 02:27:56 -07:00
Jan Kessler 0615c11a53 fix sentinel connection being attempted for non-sentinel redis 2025-03-27 10:23:35 +01:00
Timothy Jaeryang Baek 69dee19568 refac 2025-03-27 01:42:33 -07:00
Timothy Jaeryang Baek 4a79320253 chore: format 2025-03-27 01:40:28 -07:00
Timothy Jaeryang Baek d9b6d78d5c feat: external tool server support frontend 2025-03-27 01:38:35 -07:00
Jan Kessler d0b13cf388 prefix sentinel envs with redis_ 2025-03-27 09:22:11 +01:00
Timothy Jaeryang Baek 3514a6c5ed Merge pull request #12050 from mahenning/fix-db-order
Fix: Normalize all database distances to score in [0, 1] (needs testing for different DBs)
2025-03-26 20:56:02 -07:00
Timothy Jaeryang Baek 7490bc9100 Merge branch 'dev' into fix-db-order 2025-03-26 20:55:42 -07:00
Timothy Jaeryang Baek eaba9d8a67 Merge pull request #11497 from mahenning/k_reranker
feat: Added new k_reranker parameter
2025-03-26 20:50:43 -07:00
Timothy Jaeryang Baek 9d834a8e90 Merge branch 'dev' into k_reranker 2025-03-26 20:50:31 -07:00
Timothy Jaeryang Baek 79e4f8a7bc refac 2025-03-26 20:47:52 -07:00
Timothy Jaeryang Baek 5119cc47fc refac 2025-03-26 20:46:55 -07:00
Timothy Jaeryang Baek 42636347e2 refac: start source citation from 1 2025-03-26 20:39:39 -07:00
Timothy Jaeryang Baek dd2d757270 Merge pull request #12090 from panda44312/patch-7
i18n: zh-cn
2025-03-26 20:37:27 -07:00
Timothy Jaeryang Baek 08cd749160 refac 2025-03-26 20:28:41 -07:00
Panda f2bc2f704e i18n: zh-cn 2025-03-26 15:14:16 +01:00
Timothy Jaeryang Baek a650d77af4 refac 2025-03-26 01:25:01 -07:00
Timothy Jaeryang Baek c062353a3f refac 2025-03-26 01:17:10 -07:00
Timothy Jaeryang Baek c1a02ec7d8 refac 2025-03-26 01:11:12 -07:00
Timothy Jaeryang Baek 1be6ad1250 feat: /messages chat endpoint support 2025-03-26 01:10:27 -07:00
Timothy Jaeryang Baek 7dcdb1b404 refac 2025-03-26 00:43:51 -07:00
Timothy Jaeryang Baek 5a7efad59c refac: tools 2025-03-26 00:40:24 -07:00
Timothy Jaeryang Baek 82ca88105c Merge pull request #12076 from TiancongLx/dev
i18n: update zh-TW
2025-03-25 23:50:27 -07:00
Tiancong Li ec08043f23 i18n: update zh-TW 2025-03-26 13:34:53 +08:00
Timothy Jaeryang Baek 823d9f0475 Merge pull request #12043 from Espinaydelfin/main
i18n: Agregar idioma Galego (Add Galician Language)
2025-03-25 21:02:16 -07:00
Timothy Jaeryang Baek 1a04028507 Merge pull request #12051 from PeterDaveHello/Update-zh-TW-locale
i18n: minor zh-TW Traditional Chinese translation fix
2025-03-25 21:01:12 -07:00
Timothy Jaeryang Baek 8fe4c51e26 Merge pull request #12054 from philipp-horstenkamp/main
i18n: Fixed typo in german translation
2025-03-25 21:00:57 -07:00
Timothy Jaeryang Baek 9ef5868226 Merge pull request #12056 from ivanbaldo/fix-tika-spaces
**fix**: Tika 3.1.0.0 sends a lot of blank lines which degrades the RAG results and don't look good on the UI, strip them.
2025-03-25 21:00:49 -07:00
Marko Henning 7531b7dcaa Satisfy github format check 2025-03-25 19:09:17 +01:00
Iván Baldo 115e46a6a2 Fix: Tika 3.1.0.0 sends a lot of blank lines which degrades the RAG results, strip them. 2025-03-25 14:53:14 -03:00
Philipp Horstenkamp ee263b2f2b Fixed typo in german translation 2025-03-25 17:19:35 +01:00
Peter Dave Hello ca50e339cd i18n: minor zh-TW Traditional Chinese translation fix 2025-03-26 00:04:33 +08:00
Marko Henning 94d9d3d590 Fix: Normalze all database distances to score in [0, 1] 2025-03-25 16:46:14 +01:00
Espinaydelfin 5cf730bba7 Agregar idioma Galego 2025-03-25 13:31:37 +01:00
Timothy Jaeryang Baek 4906af9319 refac: styling 2025-03-25 00:12:23 -07:00
Timothy Jaeryang Baek 0192b4b468 refac: styling 2025-03-25 00:10:09 -07:00
Timothy Jaeryang Baek 57d1527ce1 refac 2025-03-25 00:07:04 -07:00
Timothy Jaeryang Baek 48b89322fd Merge pull request #12009 from PeterDaveHello/Dockerfile
build: add one missing `--no-cache-dir` for `pip3 install` in Dockerfile
2025-03-24 11:44:25 -07:00
Timothy Jaeryang Baek 16018b75a3 Merge pull request #12016 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-03-24 11:43:24 -07:00
Simon 0418416355 Update translation.json 2025-03-24 19:41:55 +01:00
Simon 1db46c31aa Merge branch 'open-webui:dev' into dev 2025-03-24 19:41:07 +01:00
Simon 4f06771559 Update translation.json 2025-03-24 19:40:30 +01:00
Timothy Jaeryang Baek 6ed617e9f5 Merge pull request #12006 from PeterDaveHello/Update-zh-TW-locale
i18n: Improve zh-TW Traditional Chinese translation
2025-03-24 11:36:54 -07:00
Timothy Jaeryang Baek f5590b2fc0 Merge pull request #12014 from PeterDaveHello/FixPullRequestTemplate
docs: fix spelling and grammatical issues in PR template
2025-03-24 11:36:40 -07:00
Timothy Jaeryang Baek 38d524f6a0 chore: format 2025-03-24 11:35:32 -07:00
Peter Dave Hello ed2ecca475 docs: fix spelling and grammatical issues in PR template 2025-03-25 00:26:21 +08:00
Peter Dave Hello aaf47486da build: add one missing --no-cache-dir for pip3 install in Dockerfile 2025-03-24 20:45:16 +08:00
Peter Dave Hello c1f1af97c1 i18n: Improve zh-TW Traditional Chinese translation 2025-03-24 04:27:14 +08:00
Timothy Jaeryang Baek d81d1333ba Merge pull request #11950 from shirayu/feat_auth_redirect
feat: Adding auth redirect parameter
2025-03-23 11:53:45 -07:00
Timothy Jaeryang Baek 9de3e6d716 Merge pull request #11971 from shirayu/remove_ENABLE_AUDIT_LOGS
Remove `ENABLE_AUDIT_LOGS`
2025-03-23 11:53:12 -07:00
Timothy Jaeryang Baek e5b7188379 refac: ollama only param 2025-03-23 11:50:40 -07:00
Timothy Jaeryang Baek a00e5a0c0e Merge pull request #11988 from binxn/dev
Updated middleware.py to add OpenRouter compatibility
2025-03-23 11:48:21 -07:00
Timothy Jaeryang Baek c94a1ec8f8 Merge pull request #11989 from MaxJa4/dev
i18n: Translation for de-DE German
2025-03-23 11:47:15 -07:00
Timothy Jaeryang Baek c714bd8739 refac 2025-03-23 11:45:55 -07:00
MaxJa4 f2866ed858 Adjust naming 2025-03-23 17:35:28 +01:00
MaxJa4 137f16a160 Rename chats 2025-03-23 17:35:23 +01:00
MaxJa4 73715538ed Fix spelling error 2025-03-23 17:35:23 +01:00
MaxJa4 e4078a6aee Add new translations 2025-03-23 17:35:23 +01:00
binxn efd86e2cb4 Updated middleware.py to add OpenRouter compatibility 2025-03-23 17:14:20 +01:00
Yak! c1f189a602 Fix inconsistent value check. 2025-03-23 17:52:48 +09:00
Yuta Hayashibe d144592660 chore: Remove ENABLE_AUDIT_LOGS and set the AUDIT_LOG_LEVEL NONE 2025-03-23 11:34:04 +09:00
Timothy Jaeryang Baek 75b18f92b9 refac 2025-03-22 14:01:07 -07:00
Timothy Jaeryang Baek 07a15494e5 Merge pull request #11955 from jfbloom22/dev
fix: Improve error handling in ChromaDB delete function for non-existent collections
2025-03-22 12:46:01 -07:00
Jonathan Flower bdd236fa3a improved error handling for deleting collections that do not exist in chromadb 2025-03-22 09:59:06 -04:00
Yuta Hayashibe 966940cb00 feat: Added redirect parameter to /auth 2025-03-22 15:04:46 +09:00
Timothy Jaeryang Baek f872bda19b Merge pull request #11936 from softue/auth-redirect
fix: redirection for users already logged in
2025-03-21 12:33:52 -07:00
Timothy Jaeryang Baek 22f6e0f2f4 refac 2025-03-21 08:08:15 -07:00
Timothy Jaeryang Baek 8aa6dade41 Merge pull request #11876 from mahenning/fix--rag-sorting
Fix: wrong citation order for chromadb, wrong order for hybrid search
2025-03-20 17:54:22 -07:00
Timothy Jaeryang Baek d047eb46cc refac 2025-03-20 17:54:13 -07:00
Timothy Jaeryang Baek 87a06a1976 fix: file delete from knowledge not working with bypass embedding 2025-03-20 17:46:11 -07:00
Timothy Jaeryang Baek a28436237c refac 2025-03-20 17:42:50 -07:00
Timothy Jaeryang Baek 558295f728 Merge pull request #11862 from hurxxxx/feat/auto-edit-new-folder
feat: Automatically enter edit mode when creating a new folder
2025-03-20 17:40:37 -07:00
Timothy Jaeryang Baek ca43a4e97a Merge pull request #11926 from djismgaming/spanish-translation-20250320
i18n: update Spanish translation
2025-03-20 17:24:36 -07:00
djismgaming cbd11cffa0 chore: update translation.json 2025-03-20 18:11:33 -04:00
Timothy Jaeryang Baek 1813bb509b Merge pull request #11830 from hurxxxx/feat/chat-title-rename-enter-submit
feat: submit chat title rename with Enter, cancel with ESC
2025-03-20 14:03:59 -07:00
Timothy Jaeryang Baek 1d305e7b2f chore: format 2025-03-20 14:02:33 -07:00
Timothy Jaeryang Baek 9b20ef4922 refac 2025-03-20 14:01:47 -07:00
Timothy Jaeryang Baek f066eea92e Merge pull request #11899 from genjuro214/set-playwright-timeout
perf: set shorter timeout for playwright and make it configurable
2025-03-20 14:00:59 -07:00
Timothy Jaeryang Baek b96557c46e refac: styling 2025-03-20 13:55:13 -07:00
Timothy Jaeryang Baek 13220991a4 Merge pull request #11846 from hurxxxx/feat/sidebar-chat-search-clear-button
feat: add clear ("X") button to sidebar chat search input
2025-03-20 13:50:58 -07:00
Timothy Jaeryang Baek 132a332ac7 Merge pull request #11915 from Alluuu/i18n-add-estonian-language
i18n: Add Estonian et-EE translations
2025-03-20 13:50:23 -07:00
Timothy Jaeryang Baek 42f3e40065 Merge pull request #11911 from diwakar-s-maurya/patch-1
fix: error message propagation from filter-pipeline
2025-03-20 13:48:36 -07:00
Rômulo Mendes Figueiredo c4c6e02b4c fix: redirection for users already logged in 2025-03-20 16:05:26 -03:00
Alluuu 5b276471b3 Added Estonian language translations.
Tried to organize language list, following existing pattern as best, as I could tell: English first, Alphabetical languages, finished by Chinese and Doge
2025-03-20 18:42:45 +02:00
Diwakar 2bdf77a726 Fix error message propagate from pipelines
Error message returned from pipelines was not being shown on UI. It showed "Connection closed". With this fix it will show the error message on the UI from the pipeline properly.
2025-03-20 22:20:27 +07:00
genjuro 07098c6352 perf: set shorter timeout for playwright and make it configurable 2025-03-20 15:28:09 +08:00
Marko Henning 5f48af5b91 Revert the ordering change with chromadb, not necessary with reranker results 2025-03-19 17:04:45 +01:00
Timothy Jaeryang Baek 70550e41fc enh: user groups/permissions endpoint 2025-03-19 08:47:31 -07:00
Timothy Jaeryang Baek c69d1c86fe enh: apply file size limit to knowledge 2025-03-19 08:36:41 -07:00
Timothy Jaeryang Baek f806ab0bd2 refac 2025-03-19 08:32:31 -07:00
hurxxxx 18a8a375ab fix: Enter edit mode with text pre-selected 2025-03-20 00:27:53 +09:00
Marko Henning ec8fc727b8 Fix wrong order for chromadb 2025-03-19 16:06:10 +01:00
Timothy Jaeryang Baek 6ace00df24 Merge pull request #11839 from tcgumus/patch-2
Turkish language pack updates
2025-03-19 06:24:44 -07:00
Timothy Jaeryang Baek b4c00b2eb2 Merge pull request #11833 from aleixdorca/dev
Update Catalan translation.json
2025-03-19 06:24:23 -07:00
Timothy Jaeryang Baek 4b10051326 Merge pull request #11832 from TiancongLx/dev
i18n: update zh-TW
2025-03-19 06:24:15 -07:00
Timothy Jaeryang Baek 01c236ee59 Merge pull request #11826 from panda44312/patch-6
i18n: zh-cn
2025-03-19 06:24:09 -07:00
Timothy Jaeryang Baek f28bec876e Merge pull request #11853 from genjuro214/fix-issue-11847
fix: correct incorrect usage of log.exception method
2025-03-19 06:22:56 -07:00
hurxxxx 11f2aaf7b1 feat: Automatically enter edit mode when creating a new folder 2025-03-19 21:55:15 +09:00
leilibj 3e8546135d fix: correct incorrect usage of log.exception method 2025-03-19 13:04:34 +08:00
hurxxxx 70610f8343 feat: add clear ("X") button to sidebar chat search input 2025-03-19 11:25:16 +09:00
Timothy Jaeryang Baek 6c4352de07 fix: table cells format 2025-03-18 17:53:11 -07:00
Tuna Çağlar Gümüş d68a6227ad Turkish language pack updates 2025-03-19 00:15:39 +03:00
Aleix Dorca bda5e0af74 Update Catalan translation.json 2025-03-18 20:08:16 +01:00
Tiancong Li 05fa67ae8f i18n: update zh-TW 2025-03-19 02:54:51 +08:00
hurxxxx 2b687e2c06 Use consistent function names. 2025-03-19 02:23:30 +09:00
hurxxxx 3b624f35ac feat: submit chat title rename with Enter, cancel with ESC 2025-03-19 02:00:58 +09:00
Marko Henning 5ab789e83e Add documentation on chroma special case 2025-03-18 16:44:58 +01:00
Marko Henning ba676b7ed6 Use k_reranker also for result merge, and add special sorting use case for ChromaDB 2025-03-18 16:25:24 +01:00
Panda 87a3a893e2 i18n: zh-cn 2025-03-18 15:54:03 +01:00
Timothy Jaeryang Baek 62e63cffe7 Merge pull request #11804 from djismgaming/spanish-translation-20250317
i18n: translated new language pairs to Spanish
2025-03-18 13:44:45 +00:00
Timothy Jaeryang Baek 20128e6aa8 Merge branch 'dev' into spanish-translation-20250317 2025-03-18 13:44:32 +00:00
Timothy Jaeryang Baek 9a9a8a4b28 Merge pull request #11808 from FarisHijazi/dev
FIX #5682 dates not surviving importing
2025-03-18 13:43:41 +00:00
Timothy Jaeryang Baek b609b9d297 chore: format 2025-03-18 06:39:42 -07:00
Timothy Jaeryang Baek 3186aeac08 chore: format 2025-03-18 06:39:37 -07:00
Marko Henning f13948d805 Fixed typo 2025-03-18 12:14:59 +01:00
Marko Henning c877b59cbc Address edge case with k < k_reranker, sort results for cutting off 2025-03-18 11:31:17 +01:00
Jan Kessler e68cd9b671 Redis Sentinel support for AppConfig 2025-03-18 09:30:18 +01:00
Jan Kessler 9167a8bef0 refac as prep for sentinel support in AppConfig 2025-03-18 08:44:50 +01:00
Jan Kessler 9bf663934a fixes & add envs for Sentinel implementation 2025-03-18 07:49:57 +01:00
Jan Kessler 4370332e32 second part of adding Redis Sentinel support 2025-03-18 07:49:57 +01:00
Jan Kessler 3b357746d8 first part of adding Redis Sentinel support 2025-03-18 07:49:56 +01:00
Faris Hijazi eff1a23c19 fixed 5682 dates not surviving importing
Simply replaced the frontend svelte code that does the importing. One of
the keys were wrong, it was `convo['timestamp']` instead of
`convo['create_time']`
2025-03-18 09:23:52 +03:00
djismgaming e2f6cdcf31 translated new languague pairs to Spanish 2025-03-17 23:18:24 -04:00
Timothy Jaeryang Baek f5c5a4119f Merge pull request #11785 from SadmL/dev
[i18n] Almost full Russian localization (rebased)
2025-03-18 01:28:02 +00:00
Timothy Jaeryang Baek 47d5e21061 Merge pull request #11786 from taylorwilsdon/scroll_enh
fix: Better visibility for scroll bar in webkit browsers
2025-03-18 01:25:58 +00:00
SadmL 9a55071d65 Update translation.json 2025-03-17 20:02:22 +03:00
Taylor Wilsdon 1119b06678 Widen scrollbar so that it can be clicked and dragged with a mouse or trackpad 2025-03-17 13:00:55 -04:00
Timothy Jaeryang Baek 54d5281256 refac 2025-03-15 18:41:04 +00:00
Timothy Jaeryang Baek 0777ab7011 refac 2025-03-15 18:28:02 +00:00
Timothy Jaeryang Baek d8eade736f refac 2025-03-15 18:23:40 +00:00
Timothy Jaeryang Baek b6e56c0e5a refac 2025-03-15 18:00:18 +00:00
Timothy Jaeryang Baek cfb2319923 refac: disable autocompletion by default 2025-03-15 17:40:39 +00:00
Timothy Jaeryang Baek 7de88cc4eb refac 2025-03-15 17:19:22 +00:00
Timothy Jaeryang Baek 10b9fa6ed0 refac 2025-03-15 17:16:19 +00:00
Timothy Jaeryang Baek 4f5aba8be7 refac 2025-03-15 17:13:40 +00:00
Timothy Jaeryang Baek d93828e923 enh: client-side pdf generation 2025-03-15 17:11:14 +00:00
Timothy Jaeryang Baek c57db1828f fix 2025-03-15 16:25:21 +00:00
Timothy Jaeryang Baek b5e74f4eae refac 2025-03-15 16:15:37 +00:00
Timothy Jaeryang Baek c8d02aafd9 Merge pull request #11697 from foraxe/fix-keep-alive-top-level
fix: Promote keep_alive to top-level in payload for Ollama API (dev branch)
2025-03-15 14:43:27 +00:00
Timothy Jaeryang Baek f1a84f45c8 Merge pull request #11699 from shaun-gallagher-octane/markdown-alert-renderer
feat: Markdown alert renderer
2025-03-15 02:12:16 +00:00
Timothy Jaeryang Baek 0551b90d47 Merge pull request #11611 from nikes/feat/pip-options
feat: pip options in tools/functions install requirements
2025-03-15 02:07:31 +00:00
Timothy Jaeryang Baek 2de03494a6 Merge pull request #11647 from kaytwo/multiline-valves
feat: allow string valves to include newlines
2025-03-15 02:05:56 +00:00
Timothy Jaeryang Baek aa37866798 chore: pyodide bump 2025-03-15 02:04:18 +00:00
Timothy Jaeryang Baek 7723705707 enh: always collapse code block 2025-03-15 02:01:59 +00:00
Timothy Jaeryang Baek dbf051ff4e enh: always expand details option 2025-03-15 01:56:04 +00:00
Shaun Gallagher 2065580a5f feat: Use AlertRenderer in blockquote evaluation 2025-03-14 15:22:56 -04:00
Shaun Gallagher 8bc797fe6a feat: Add renderer for Markdown alerts 2025-03-14 15:22:27 -04:00
foraxe 04046ed6c8 Alternative Temp Fix: Uncomment to Keep Alive Forever 2025-03-15 00:27:20 +08:00
foraxe 53448b3f0a Fix: Promote keep_alive to top-level in payload for Ollama API on dev branch 2025-03-15 00:08:05 +08:00
Timothy Jaeryang Baek 74da9c4e54 fix: firefox input height issue 2025-03-14 12:50:33 +00:00
Timothy Jaeryang Baek 4b1899bfca chore: dependency 2025-03-14 12:35:33 +00:00
Timothy Jaeryang Baek 8588b52fd6 Merge pull request #11605 from melroy89/update_fake_useragent
dependency: Update fake-useragent to v2.1.0
2025-03-14 11:52:30 +00:00
Timothy Jaeryang Baek 4025d61c33 Merge pull request #11636 from OliveiraHermogenes/fix-api-audio-mimetype-check
fix: audio api endpoint filetype check
2025-03-14 11:52:12 +00:00
Chris Kanich 53292e7d12 allow string valves to include newlines 2025-03-13 12:04:13 -05:00
Nikes b6664f5ae8 feat: pip options in tools/functions install requirements 2025-03-12 23:15:26 +02:00
Melroy van den Berg b02e6c6734 Deps: Update fake-useragent to v2.1.0 2025-03-12 20:41:51 +01:00
Timothy Jaeryang Baek d84e7d13d0 refac 2025-03-11 20:49:14 +00:00
Timothy Jaeryang Baek c309412980 enh: connection tags 2025-03-11 20:37:30 +00:00
Timothy Jaeryang Baek b427f506f6 fix 2025-03-11 20:12:34 +00:00
Timothy Jaeryang Baek ad51b0166c refac 2025-03-11 20:12:28 +00:00
Timothy Jaeryang Baek e1168139a6 refac 2025-03-11 19:30:09 +00:00
Timothy Jaeryang Baek 9e2fb73440 Merge pull request #11550 from silentoplayz/highlight-selected
feat: improve model dropdown highlight
2025-03-11 18:57:20 +00:00
Timothy Jaeryang Baek adfa67d9d2 Merge pull request #11507 from OrenZhang/feat_ot
feat(trace): opentelemetry instrument
2025-03-11 18:55:56 +00:00
Timothy Jaeryang Baek 396c28817c refac 2025-03-11 18:55:30 +00:00
Timothy Jaeryang Baek 7e0c5a44e3 Merge pull request #11552 from icsy7867/dev
Fixed knowledgebase view for decodeURIComponent
2025-03-11 18:53:17 +00:00
root 6fc9c69b9f Fixed knowledgebase view for decodeURIComponent 2025-03-11 14:44:04 -04:00
Timothy Jaeryang Baek 9adc1ebcc8 refac 2025-03-11 18:38:13 +00:00
Timothy Jaeryang Baek 6399aaf4b2 Merge pull request #11515 from davizucon/main
fix: url value when response contains url tag
2025-03-11 18:37:05 +00:00
Timothy Jaeryang Baek 16c383ab44 Merge pull request #11498 from tcgumus/patch-2
Add SSL Integration Comments and WebSocket Auto-Fix in start_windows.bat
2025-03-11 18:34:54 +00:00
Timothy Jaeryang Baek 8905d9e63b Merge pull request #11526 from rragundez/exlude-images-from-embeddings
Exlude images from embeddings
2025-03-11 18:32:38 +00:00
Timothy Jaeryang Baek 64fa37c15d Merge pull request #11545 from hurxxxx/fix/reset-taskId-after-conversation
fix: Reset taskId after completion to prevent incorrect cancellation behavior
2025-03-11 18:29:59 +00:00
Silentoplayz 2c99366543 feat: improve model dropdown highlight 2025-03-11 13:57:09 -04:00
Davi S. Zucon b1224b6839 fix code format 2025-03-11 12:40:31 -03:00
Davi S. Zucon 453200ca1e fix code format
fix code format
2025-03-11 12:39:15 -03:00
hurxxxx 1160e21d45 fix: reset taskId after conversation to prevent incorrect cancellation behavior 2025-03-11 21:18:53 +09:00
Timothy Jaeryang Baek 0e7164b4f5 Merge pull request #11520 from icsy7867/main
Added decodeURIcomponent function to several locations.
2025-03-11 10:33:41 +00:00
Rodrigo Agundez ac321f4b64 Add check if images and avoid trying to calculate embeddings 2025-03-11 12:50:44 +08:00
orenzhang 7bfda6652f feat(trace): optimize for trace env and instrument hooks 2025-03-11 11:53:03 +08:00
root 3fb477618e Added decodeURIcomponent function to several locations. 2025-03-10 17:40:40 -04:00
Tuna Çağlar Gümüş 9e7193b3d2 Update start_windows.bat 2025-03-10 22:37:57 +03:00
Davi S. Zucon 846509bf9f fix url value when response contains url tag
When the response contains b64 json, the "url" tag still exists but comes as null, the logic is to resolve this point where it contains the url tag, but empty, thus directing to b64_json
2025-03-10 15:59:08 -03:00
orenzhang c761e4fd08 feat(trace): opentelemetry instrument 2025-03-10 22:27:31 +08:00
Marko Henning 8b5b3f165a Add translation (partially) 2025-03-10 12:35:44 +01:00
Timothy Jaeryang Baek c2046b9247 refac 2025-03-10 11:16:30 +00:00
Tuna Çağlar Gümüş c5efeb2499 Add SSL Integration Comments and WebSocket Auto-Fix in start_windows.bat
This PR enhances the start_windows.bat script by:

Adding comments for SSL integration, making it easier for users to enable SSL when needed.
Including the -ws auto flag to address WebSocket-related issues, ensuring a smoother experience for users facing connection problems.
These improvements make the script more user-friendly and help troubleshoot common setup challenges.
2025-03-10 13:39:59 +03:00
Timothy Jaeryang Baek 116e0559f6 refac: oauth 2025-03-10 09:42:59 +00:00
Timothy Jaeryang Baek 428d89784b Merge pull request #11484 from xinhai-ai/mobile_touch
fix: mobile touch
2025-03-10 06:27:35 -03:00
星海 077c805880 fix: mobile touch 2025-03-10 15:43:53 +08:00
Timothy Jaeryang Baek 352ed67667 refac: prompt template behaviour 2025-03-10 01:15:45 +00:00
Timothy Jaeryang Baek ec29772068 refac: styling 2025-03-09 23:58:48 +00:00
Timothy Jaeryang Baek 3cc44b9de5 Merge pull request #11460 from silentoplayz/horizontal-scrol-tags2
feat: Horizontal scroll for tags in model dropdown selector
2025-03-09 20:58:05 -03:00
Timothy Jaeryang Baek f8ac44cfbd Merge pull request #11464 from FabioPolito24/docling_context_extraction_engine
feat: Docling context extraction engine
2025-03-09 20:57:48 -03:00
Timothy Jaeryang Baek 0c5a1e84e3 Merge pull request #11472 from thiswillbeyourgithub/better_frontmatter_pip_install3
fix: install all the requirements at the same time
2025-03-09 20:57:06 -03:00
Timothy Jaeryang Baek 1fe4d49ac6 Merge pull request #11466 from captn-hook/dev
fix: opensearch environment variables fix
2025-03-09 20:56:34 -03:00
Timothy Jaeryang Baek 41a83f1dc5 refac: code update behaviour 2025-03-09 23:49:26 +00:00
thiswillbeyourgithub 9e41b8b024 fix: install all the requirements at the same time
Signed-off-by: thiswillbeyourgithub <26625900+thiswillbeyourgithub@users.noreply.github.com>
2025-03-09 23:38:23 +01:00
Tristan H 4c98508c0b Merge pull request #3 from open-webui/dev
Dev
2025-03-09 09:51:31 -07:00
Tristan H 084e2caf6f Update config.py to correctly handle environment strings
As seen in line 1536 just above this, strings need to be converted to bools in order to work.
2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek a75f82755d Update bug_report.yaml 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek c1d2e0d999 refac 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek 165e861a1d Create config.yaml 2025-03-09 09:48:35 -07:00
Timothy Jaeryang Baek fc056b1397 Update bug_report.yaml 2025-03-09 09:48:35 -07:00
Fabio Polito de0f158b04 Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge upstream dev
2025-03-09 16:12:43 +00:00
Fabio Polito 9d6743824e fix: fix params DoclingLoader 2025-03-09 16:12:14 +00:00
Silentoplayz ae1f0f0a3c feat: horizontal scroll for model dropdown tags 2025-03-09 11:41:14 -04:00
Timothy Jaeryang Baek cdb5eae43a refac 2025-03-09 15:05:13 +00:00
Timothy Jaeryang Baek 26980fb859 refac 2025-03-09 15:01:36 +00:00
Timothy Jaeryang Baekandrecrudesce 53a2acd541 enh: local, external, direct model list filter
Co-Authored-By: recrudesce <6450799+recrudesce@users.noreply.github.com>
2025-03-09 15:00:13 +00:00
Timothy Jaeryang Baek c58f0844a3 refac 2025-03-09 14:48:11 +00:00
Timothy Jaeryang Baek 5cc43ae880 enh: image panzoom 2025-03-09 14:39:54 +00:00
Timothy Jaeryang Baek 1c211e32b3 refac 2025-03-09 11:18:28 +00:00
Timothy Jaeryang Baek 0dce81f646 Merge pull request #11423 from silentoplayz/confirmation-prompts
feat: add more confirmation prompts
2025-03-09 07:40:36 -03:00
Timothy Jaeryang Baek b8863545d5 Merge pull request #11428 from silentoplayz/sticky-tags
feat: Sticky tags
2025-03-09 07:32:05 -03:00
Timothy Jaeryang Baek 36dd630057 Merge pull request #11444 from open-webui/main
dev
2025-03-09 07:31:02 -03:00
Silentoplayz 1de142f435 sticky tags 2025-03-08 18:00:06 -05:00
Hermógenes Oliveira e936d7b53d fix: audio api endpoint filetype check
RFC2046 allows the Content-Type field to have additional parameters
after the main type/subtype information (Section 1).

Following RFC4281, many applications put codec information inside
parameters in the Content-Type. This is especially common for formats
that support many codecs, such as Ogg (RFC5334, Section 4).

The `/api/audio/transcriptions` endpoint is currently rejecting files
that contain parameters in the Content-Type field with a bad request
error.

This commit changes the current check in order to accept any
Content-Type field that begins with a supported type/subtype as listed
in the `supported_filetypes` tuple.

Since Content-Type here is provided by the user, I believe this check
is meant to prevent honest mistakes, like posting a PDF to an audio
processing endpoint, not as a security measure against possibly
malicious use. Therefore, I think it's OK not to validate the rest of
the field.
2025-03-08 18:03:30 -03:00
Fabio Polito 0aa42615f9 Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge upstream
2025-03-08 18:52:51 +00:00
Silentoplayz d669932137 add confirmation prompts 2025-03-08 13:42:24 -05:00
Timothy Jaeryang Baek 6b806ae12f Merge pull request #11413 from hurxxxx/fix/global-filter-cannot-disable
fix: Prevent modification of global filter settings in "Admin panel" - "Settings" - "Models"
2025-03-08 14:15:47 -04:00
Timothy Jaeryang Baek b03fc97e28 Update bug_report.yaml 2025-03-08 18:13:28 +00:00
Timothy Jaeryang Baek d7bfa395b0 refac 2025-03-08 18:10:34 +00:00
Timothy Jaeryang Baek 67cd4f2ab1 Create config.yaml 2025-03-08 18:08:51 +00:00
Timothy Jaeryang Baek dc78d2f758 Update bug_report.yaml 2025-03-08 18:06:08 +00:00
Timothy Jaeryang Baek d40e696468 enh: multiple replica app state sync 2025-03-08 17:59:15 +00:00
Timothy Jaeryang Baek 324cd94c53 refac: user message delete 2025-03-08 17:03:08 +00:00
Timothy Jaeryang Baek 4e36b9794f fix: logit bias issue 2025-03-08 16:50:30 +00:00
Timothy Jaeryang Baek 58ff221d1f refac 2025-03-08 16:48:32 +00:00
Timothy Jaeryang Baek 1e36282708 refac 2025-03-08 16:23:20 +00:00
Timothy Jaeryang Baek 22b88f9593 Merge pull request #11324 from kela4/main
fix: opensearch vector db query structures, result mapping, filters, bulk query actions, knn_vector usage
2025-03-08 12:19:38 -04:00
Timothy Jaeryang Baek e4a79a0b0e Merge pull request #11394 from dannyl1u/fix/logit-bias-workspace-models
fix: logit_bias bug in workspace models
2025-03-08 12:11:53 -04:00
Timothy Jaeryang Baek 8375c2c32d Merge pull request #11397 from luke3butler/tavily-extract-depth
feat: Add environment variable to configure tavily extract depth
2025-03-08 12:11:37 -04:00
Timothy Jaeryang Baek e7013d45c5 Merge pull request #11409 from chengscott/patch-1
[feat] anonymous bind when LDAP_APP_DN is not specified
2025-03-08 12:11:13 -04:00
Timothy Jaeryang Baek ac507720c8 Merge pull request #11401 from TiancongLx/dev
i18n: update zh-TW
2025-03-08 12:10:58 -04:00
Timothy Jaeryang Baek d59433bf7e refac 2025-03-08 15:48:09 +00:00
hurxxxx 40257afeed fix: Using disabled attribute for elements is common 2025-03-09 00:12:37 +09:00
hurxxxx 851931fdf6 fix: Global filter cannot be modified in the model (changes have no effect) 2025-03-08 23:24:52 +09:00
Scott Cheng 8bd527be6a anonymous bind when LDAP_APP_DN is not specified 2025-03-08 21:19:21 +08:00
Tiancong Li e5b02f732b i18n: update zh-TW 2025-03-08 16:18:22 +08:00
Luke 7c4a9c2aea fix: correct type handling for TAVILY_EXTRACT_DEPTH configuration 2025-03-08 01:07:06 -05:00
Luke 7917128ed3 enh: enable configuration for tavily extract depth 2025-03-08 00:43:02 -05:00
dannyl1u 3c6a834ff4 fix: logit_bias in workspace models 2025-03-07 19:54:06 -08:00
Timothy Jaeryang Baek dc3abc2bcb refac 2025-03-08 00:13:58 +00:00
Timothy Jaeryang Baek 3ab917cdd0 Merge pull request #11325 from luke3butler/dev
feat: Add `tavily` as a `RAG_WEB_LOADER_ENGINE` option via extract API
2025-03-07 20:11:39 -04:00
Timothy Jaeryang Baek e3116aac76 Merge pull request #11361 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-03-07 19:59:42 -04:00
Timothy Jaeryang Baek e85d69fe60 Merge branch 'dev' into dev 2025-03-07 19:59:33 -04:00
Timothy Jaeryang Baek 027c5c4b72 Merge pull request #11383 from Kylapaallikko/dev
i18n: Update Finnish Translation
2025-03-07 19:59:03 -04:00
Timothy Jaeryang Baek 88303093c0 Merge pull request #11384 from Peter-De-Ath/hotfix/tool-calls-params
fix: bug where default params in tools calls always used
2025-03-07 19:58:05 -04:00
Peter De-Ath 7e3be60f0e formating 2025-03-07 20:37:22 +00:00
Peter De-Ath 3b39807377 fix: updatepost_response_handler nativ tools handler to use parameters from properties fixing defaults always used 2025-03-07 20:07:36 +00:00
Kylapaallikko 17a4360566 Update translation.json 2025-03-07 19:44:56 +02:00
Timothy Jaeryang Baek 7a1cf1095a Merge pull request #11358 from OrenZhang/i18n_translation
i18n(common): add i18n translation
2025-03-07 13:33:59 -04:00
Timothy Jaeryang Baek ab92737a9a refac 2025-03-07 17:24:52 +00:00
Simon 27a6d1917d Update translation.json 2025-03-07 13:48:56 +01:00
Simon 25544b38bd Merge branch 'open-webui:dev' into dev 2025-03-07 13:42:46 +01:00
Simon cf449d1c7e Update translation.json 2025-03-07 13:42:05 +01:00
Simon 20826e15b3 Update translation.json 2025-03-07 13:31:56 +01:00
Timothy Jaeryang Baek d7a0437438 refac: model sort list 2025-03-07 12:16:16 +00:00
orenzhang b61ea393b4 refactor(lint): code lint 2025-03-07 20:03:53 +08:00
orenzhang dc04123330 Merge branch 'dev' into i18n_translation 2025-03-07 20:03:33 +08:00
orenzhang 72ea6dd9f1 refactor(lint): code lint 2025-03-07 19:59:09 +08:00
Timothy Jaeryang Baek f8f54237c6 Merge pull request #11290 from panda44312/patch-5
i18n: Update zh-cn
2025-03-07 06:06:40 -04:00
Timothy Jaeryang Baek 04921be9f1 Merge pull request #11287 from Youggls/dev
fix(chat): resolve duplicate collapsible IDs causing citation modal failures
2025-03-07 06:05:13 -04:00
Fabio Polito 1a066c0357 fix: fixed lock and pyproject 2025-03-07 00:32:02 +00:00
Fabio Polito 09ccd000d8 fix: fixing lock and pyproject 2025-03-07 00:27:59 +00:00
Fabio Polito e3eef58310 feat: merge with dev 2025-03-07 00:22:47 +00:00
Fabio Polito 17dbb3ec57 fix: fix typo 2025-03-07 00:19:26 +00:00
Peter De-Ath d862295a05 fix: update chat completion tools handler to use parameters from spec fixing defaults always used 2025-03-06 23:27:52 +00:00
Luke 987954c817 feat: Add Tavily extract web loader integration 2025-03-06 18:15:18 -05:00
Katharina 6cb0c0339a fix: opensearch vector db query structures, result mapping, filters, bulk query actions, knn_vector usage 2025-03-06 23:49:54 +01:00
wangtunan 7d1a094f9b fix: stopTask func need use async/await syntax 2025-03-07 00:07:04 +08:00
wangtunan 533c99e384 fix: stopTask func need use async/await syntax 2025-03-06 23:56:41 +08:00
orenzhang 92fb1109b6 i18n(common): add i18n translation 2025-03-06 20:16:34 +08:00
Fabio Polito 98857184ff Merge remote-tracking branch 'upstream/dev' into docling_context_extraction_engine
merge with dev branch
2025-03-06 12:12:50 +00:00
Panda 98376fbbce i18n: zh-cn 2025-03-06 13:08:35 +01:00
Panda 561f4d5d69 i18n: zh-cn 2025-03-06 11:26:04 +01:00
Perry Li 9cc9df3018 fix(chat): resolve duplicate collapsible IDs causing citation modal failures
Fix an issue where clicking inline citations in subsequent chat messages failed to open the citation modal when multiple collapsible sections are present.

The root cause was duplicate "collapsible-sources" IDs assigned to all Collapsible components. This led document.getElementById() to always return the first instance, preventing subsequent messages from opening their CitationModal.

Changes:
- Modify Collapsible ID generation in Citations.svelte to use unique IDs with "collapsible-${message.id}" pattern
- Update ResponseMessage.svelte's onSourceClick handler to reference the dynamic collapsible IDs
- Ensure proper citation modal binding for each chat message's sources

Affected components:
- Collapsible (expandable content sections)
- CitationsModal (citation detail popup)

This ensures each chat message's sources are independently collapsible and maintains proper citation modal binding throughout message history.
2025-03-06 10:10:53 +00:00
Marko Henning 41a4cf7106 Added new k_reranker parameter 2025-03-06 10:47:57 +01:00
Timothy Jaeryang Baek 3b70cd64d7 Merge pull request #11257 from open-webui/dev
0.5.20
2025-03-05 19:44:45 -08:00
Timothy Jaeryang Baek 1173459eee doc: changelog 2025-03-05 19:42:41 -08:00
Timothy Jaeryang Baek c0ecff9d70 fix: pinyin keyboard 2025-03-05 19:36:30 -08:00
Timothy Jaeryang Baek d4fca9dabf chore: format 2025-03-05 19:17:41 -08:00
Timothy Jaeryang Baek aaaebfabbe chore: format 2025-03-05 19:16:59 -08:00
Timothy Jaeryang Baek 6eccf8668e fix: non-root container issue 2025-03-05 19:14:43 -08:00
Timothy Jaeryang Baekandrecrudesce 5e78a5c510 enh: enable_code_execution toggle
Co-Authored-By: recrudesce <6450799+recrudesce@users.noreply.github.com>
2025-03-05 19:06:28 -08:00
Timothy Jaeryang Baek dce24225e0 Merge pull request #11249 from TiancongLx/dev
i18n: update zh-TW
2025-03-05 18:52:34 -08:00
Timothy Jaeryang Baek f9b44c6bf2 refac 2025-03-05 18:39:32 -08:00
Timothy Jaeryang Baek f573a90a56 Merge pull request #11254 from open-webui/main
dev
2025-03-05 18:11:18 -08:00
Timothy Jaeryang Baek 23bb0d927f chore: format 2025-03-05 18:10:24 -08:00
Fabio Polito 2982893d0d fix: format fixes 2025-03-06 00:39:00 +00:00
Tiancong Li 6b0af942db i18n: update zh-TW 2025-03-06 07:23:55 +08:00
Fabio Polito 0716f96da8 style: change style in DoclingLoader 2025-03-05 23:15:55 +00:00
Timothy Jaeryang Baek f95a482c24 Merge pull request #11209 from aleixdorca/dev
Update catalan translation.json
2025-03-05 14:56:37 -08:00
Timothy Jaeryang Baek edf94d732b refac 2025-03-05 14:54:48 -08:00
Fabio Polito 9aa407dbd2 feat: merge with main 2025-03-05 22:04:34 +00:00
Timothy Jaeryang Baek 73a77abd20 Merge pull request #11245 from tupe2009/kleqon.fix-es-bugs
fix: ElasticSearch indexes delete and upsert bugs(Post 0.5.19 release)
2025-03-05 13:36:55 -08:00
ofek a8f205213c fixed es bugs 2025-03-05 23:19:56 +02:00
Fabio Polito a44b35e99e fix: fix DoclingLoader input params 2025-03-05 17:53:45 +00:00
Timothy Jaeryang Baek 4770285c04 Merge pull request #11211 from open-webui/dev
fix: site.webmanifest
2025-03-05 00:45:21 -08:00
Timothy Jaeryang Baek 1639fbb544 fix: site.webmanifest 2025-03-05 00:45:04 -08:00
Aleix Dorca 2dc5f3c30c Update catalan translation.json 2025-03-05 08:42:16 +01:00
Timothy Jaeryang Baek 1a51584fe0 Merge pull request #10939 from open-webui/dev
0.5.19
2025-03-04 22:22:20 -08:00
Timothy Jaeryang Baek 976c94ba5a chore: pyproject 2025-03-04 22:13:51 -08:00
Timothy Jaeryang Baek 4df74690e1 doc: changelog 2025-03-04 22:10:51 -08:00
Timothy Jaeryang Baek e2d60bd92e chore: format 2025-03-04 22:10:47 -08:00
Timothy Jaeryang Baek c2eada36f1 Merge pull request #10898 from hurxxxx/fix/update-msal-browser-version
fix: update msal browser version
2025-03-04 21:56:22 -08:00
Timothy Jaeryang Baek 0ce6975ec8 chore: bump 2025-03-04 21:49:42 -08:00
Timothy Jaeryang Baek 1ddc56fcf8 Merge pull request #11201 from open-webui/main
dev
2025-03-04 21:43:33 -08:00
Timothy Jaeryang Baek 7f7365db4f Merge pull request #11132 from dongs0104/dev
fix: email claim constant
2025-03-04 21:39:28 -08:00
Timothy Jaeryang Baek c9e2ba56c6 Merge pull request #11198 from xinhai-ai/refa_filters
chore: move the filter_type check earlier to avoid unnecessary valve reads.
2025-03-04 21:27:36 -08:00
Timothy Jaeryang Baek b01c9184c2 chore: format 2025-03-04 21:24:17 -08:00
星海 3ee1ae8c95 refac: filters 2025-03-05 13:05:14 +08:00
星海 8ce6c8cf78 refac: filters 2025-03-05 13:00:45 +08:00
Timothy Jaeryang Baek fbf4f5eb30 refac 2025-03-04 18:19:05 -08:00
Timothy Jaeryang Baek 6c77e6fdb3 refac 2025-03-04 18:08:43 -08:00
Timothy Jaeryang Baek 3b74431ea3 refac: filters 2025-03-04 18:04:55 -08:00
Timothy Jaeryang Baek 53fe7c460b Merge pull request #11178 from silentoplayz/fix
chore: Update ToolkitEditor.svelte
2025-03-04 17:53:47 -08:00
Timothy Jaeryang Baek f39556b933 Merge pull request #11163 from tidely/pathfix
chore: Use platform specific path separators
2025-03-04 17:53:21 -08:00
Timothy Jaeryang Baek cffd02defe Merge pull request #11153 from panda44312/patch-4
i18n: update ZH-CN
2025-03-04 17:42:05 -08:00
Timothy Jaeryang Baek bdc6088159 Merge pull request #11151 from TiancongLx/dev
i18n: update zh-TW
2025-03-04 17:41:57 -08:00
Timothy Jaeryang Baek 2a75a85556 refac 2025-03-04 17:40:32 -08:00
Silentoplayz 94c025c6d7 Update ToolkitEditor.svelte
fix
2025-03-04 17:46:24 -05:00
Timothy Jaeryang Baek 8961bc2453 Merge pull request #11173 from TFWol/patch-2
chore: Revamp Feature Request template
2025-03-04 13:49:07 -08:00
TFWol cb82c7062a Revamp Feature Request template 2025-03-04 11:18:50 -08:00
tidely b15814c42f chore: remove unnecessary Path conversions
Remove unnecessary `pathlib.Path` conversions. (CACHE_DIR and DATA_DIR)

Use `/` Path joining shorthand to ensure using platform specific Path separators (Windows: \\, Unix: /)
2025-03-04 19:53:52 +02:00
Panda 023c8bb5b4 i18n: zh-cn Update 2025-03-04 16:08:06 +01:00
Tiancong Li 500440ebec i18n: update zh-TW 2025-03-04 22:51:24 +08:00
Timothy Jaeryang Baek b94de4f002 refac 2025-03-04 02:45:05 -08:00
Timothy Jaeryang Baek 039a1e1f14 refac: assets 2025-03-04 01:47:17 -08:00
Timothy Jaeryang Baek 14a6c1f496 refac: favicon 2025-03-04 01:30:22 -08:00
Timothy Jaeryang Baek 0a295ff56d refac: icons 2025-03-04 01:25:47 -08:00
Timothy Jaeryang Baek 36ffa9824b refac 2025-03-04 01:16:25 -08:00
Timothy Jaeryang Baek e3769c1073 refac 2025-03-04 01:16:08 -08:00
Timothy Jaeryang Baek d78fe4decc Merge pull request #11073 from vincent-thevenin/dev
fix: fix issue with call voice recording starting before voice detection
2025-03-04 00:55:03 -08:00
Timothy Jaeryang Baek 7b442e4be0 Merge pull request #11141 from Youggls/dev
fix: correct parameter name for MilvusClient instantiation
2025-03-04 00:54:49 -08:00
Timothy Jaeryang Baek d17ac95f95 Merge pull request #11143 from panda44312/patch-3
i18n: zh-cn
2025-03-04 00:53:31 -08:00
Panda 59c3203990 IUpdate translation.json 2025-03-04 09:48:03 +01:00
Timothy Jaeryang Baek a4747c88e0 refac 2025-03-04 00:33:19 -08:00
Timothy Jaeryang Baek 39ea59edc8 chore: format 2025-03-04 00:32:27 -08:00
Perry Li 67ed61d022 fixbug: correct parameter name for MilvusClient instantiation
Replace incorrect parameter 'database=MILVUS_DB' with valid 'db_name=MILVUS_DB'
2025-03-04 16:02:19 +08:00
Dong Shin b8f3abda5a fix: email claim constant 2025-03-04 15:50:04 +09:00
Timothy Jaeryang Baek 8697f72068 refac: styling 2025-03-03 22:44:03 -08:00
Timothy Jaeryang Baek 3e7077bb73 refac 2025-03-03 22:24:19 -08:00
Timothy Jaeryang Baek 5f89c1b137 enh: model selector tag filter 2025-03-03 21:47:27 -08:00
Timothy Jaeryang Baek 811e1f92b1 refac 2025-03-03 21:27:48 -08:00
Timothy Jaeryang Baek 15cbccc8f5 enh: code block collapse 2025-03-03 21:10:15 -08:00
Timothy Jaeryang Baek 1cfa491179 Merge pull request #10868 from tidely/loggingnamemap
chore: use logging.getLevelNamesMapping() for validating log level
2025-03-03 20:55:22 -08:00
Timothy Jaeryang Baek 35e7e07821 refac: cuda error gc 2025-03-03 20:55:07 -08:00
Timothy Jaeryang Baek 9ff54288ef refac 2025-03-03 20:53:14 -08:00
Timothy Jaeryang Baek baca6db680 Merge pull request #10987 from alpha-pet/feat-submit-on-ctrl-enter
feat: add alternative input configuration where Ctrl-Enter is required to send chat messages
2025-03-03 20:39:53 -08:00
Timothy Jaeryang Baek 6bd2bc5683 refac 2025-03-03 20:34:45 -08:00
Timothy Jaeryang Baek 029625ec4e refac 2025-03-03 20:34:17 -08:00
Timothy Jaeryang Baek 7e65f7fa4d fix: chat event handler not being registered on sign in 2025-03-03 20:31:35 -08:00
Timothy Jaeryang Baek 80f5573cf3 refac: AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST 2025-03-03 20:24:10 -08:00
Timothy Jaeryang Baek 1236157800 fix: timestamp missing in edited user message 2025-03-03 20:21:39 -08:00
Timothy Jaeryang Baek e628bfe6ff fix 2025-03-03 20:16:40 -08:00
Timothy Jaeryang Baek bb2bd7d721 Merge pull request #11120 from OrenZhang/fix_jupyter
fix(jupyter): fix kernel_id not set and optimize code
2025-03-03 20:15:09 -08:00
Timothy Jaeryang Baek 9c107f9d2d refac: images "url" fallback 2025-03-03 20:07:59 -08:00
orenzhang fafd847f18 refactor(jupyter): change log message for execution failed 2025-03-04 12:01:08 +08:00
orenzhang 3a7e5a5f31 feat(jupyter): check response status for destroy kernel 2025-03-04 12:00:04 +08:00
orenzhang 613d278afc refactor(jupyter): change log level 2025-03-04 11:58:32 +08:00
orenzhang 96e0c4d7b8 refactor(jupyter): replace httpx with aiohttp 2025-03-04 11:56:11 +08:00
Timothy Jaeryang Baek 46cea1f904 fix: ldap email case sensitive 2025-03-03 19:52:27 -08:00
Timothy Jaeryang Baek d844fc7edb refac: inline citation handling 2025-03-03 19:48:00 -08:00
Timothy Jaeryang Baek 3468dda556 refac: rm unused api endpoint 2025-03-03 19:33:55 -08:00
Timothy Jaeryang Baek e6ff4169e1 Merge pull request #11066 from NibbinNone/dev
feat: add support for reasoning_content
2025-03-03 16:37:28 -08:00
Timothy Jaeryang Baek 6f8c1a8f0d Merge pull request #11089 from tupe2009/kleqon.feat-add-elasticsearch-support
feat: Elasticsearch as a vector store support
2025-03-03 16:34:39 -08:00
Timothy Jaeryang Baek d529be86fe Merge pull request #11109 from panda44312/patch-2
i18n: Update zh-cn
2025-03-03 16:34:21 -08:00
Panda 3ad4b10470 i18n: Update zh-cn 2025-03-04 00:17:02 +01:00
Timothy Jaeryang Baek 376fce273e Merge pull request #11079 from MeteorSkyOne/dev
fix: allow error returns None
2025-03-03 14:57:54 -08:00
Timothy Jaeryang Baek c19960002b Merge pull request #11086 from aindriu80/main
(i18n)Updated Irish translations
2025-03-03 14:57:01 -08:00
Timothy Jaeryang Baek dc2b8fd327 Merge branch 'dev' into main 2025-03-03 14:56:48 -08:00
ofek 737dfd2763 added elasticsearch support 2025-03-03 23:39:42 +02:00
tarmst 1ad80490de Add read/write access control for files from knowledge 2025-03-03 20:03:21 +00:00
Aindriú Mac Giolla Eoin 3fa323d1a1 Updated Irish translations 2025-03-03 18:41:11 +00:00
MeteorSky a01ef6cfa0 fix: allow error returns None 2025-03-04 00:08:49 +08:00
Timothy Jaeryang Baek c7d4d2e41f Merge pull request #11067 from TiancongLx/dev
i18n: update zh-TW (fix typo)
2025-03-03 06:49:27 -08:00
orenzhang 744ffbb1fb fix(jupyter): fix kernel_id not set and optimize code 2025-03-03 22:18:02 +08:00
vincent-thevenin 4d6132b9de fix: fix issue with call voice recording starting before voice detection 2025-03-03 13:59:28 +01:00
Tiancong Li 7a31ff6a2f i18n: update zh-TW (fix typo) 2025-03-03 16:55:37 +08:00
CorbinChen 516f1cf67b feat: add support for reasoning_content 2025-03-03 16:47:35 +08:00
Timothy Jaeryang Baek 3d6e48b05e Merge pull request #11043 from TiancongLx/dev
i18n: update zh-TW
2025-03-02 12:16:56 -08:00
Tiancong Li 3dd492627e i18n: update zh-TW 2025-03-02 17:04:58 +08:00
Timothy Jaeryang Baek 66210b95fc Merge pull request #11038 from satlus/pwa-fix-linkrel
fixed manifest in app.html restoring PWA
2025-03-01 19:55:08 -08:00
satlus 6e58d99920 fixed manifest link rel, restoring PWA 2025-03-01 19:43:40 -05:00
Timothy Jaeryang Baek 6471f12668 Merge pull request #11033 from dtaivpp/main
fix: Changed to use collection_name and fixed bulk indexing missing index.
2025-03-01 16:00:13 -08:00
David Tippett f3c4c2b8e3 Changed to use colleciton name and fixed bulk indexing missing index. 2025-03-01 13:26:19 -05:00
Timothy Jaeryang Baek a73b6448c5 Merge pull request #11032 from cheahjs/chore/build-arm-on-arm
ci: Update GitHub Actions runner selection for multi-platform builds
2025-03-01 08:23:54 -08:00
Jun Siang Cheah 3238981202 ci: Update GitHub Actions runner selection for multi-platform builds 2025-03-01 16:04:26 +00:00
Timothy Jaeryang Baek 59ddce1ea3 chore: bump 2025-03-01 08:00:40 -08:00
Timothy Jaeryang Baek 2b025b267a chore: bump 2025-03-01 07:57:08 -08:00
Timothy Jaeryang Baek efe8c4ca69 chore: format 2025-03-01 07:28:00 -08:00
Timothy Jaeryang Baek d7088efe73 fix: "stream" hook not working 2025-03-01 06:56:24 -08:00
Timothy Jaeryang Baek 05c5e73304 Merge pull request #10373 from dannyl1u/logit_bias
feat: logit bias
2025-03-01 06:13:19 -08:00
Timothy Jaeryang Baek f00f518abe Merge pull request #11011 from open-webui/dependabot/pip/backend/dev/sqlalchemy-2.0.38
chore(deps): bump sqlalchemy from 2.0.32 to 2.0.38 in /backend
2025-02-28 18:44:20 -08:00
Timothy Jaeryang Baek 639f4a44e0 Merge pull request #11013 from open-webui/dependabot/pip/backend/dev/black-25.1.0
chore(deps): bump black from 24.8.0 to 25.1.0 in /backend
2025-02-28 18:44:14 -08:00
Timothy Jaeryang Baek 92e1c654ce Merge pull request #11014 from open-webui/dependabot/pip/backend/dev/peewee-3.17.9
chore(deps): bump peewee from 3.17.8 to 3.17.9 in /backend
2025-02-28 18:44:07 -08:00
Timothy Jaeryang Baek 81d2df4df9 Merge pull request #11015 from open-webui/dependabot/pip/backend/dev/bcrypt-4.3.0
chore(deps): bump bcrypt from 4.2.0 to 4.3.0 in /backend
2025-02-28 18:43:57 -08:00
Timothy Jaeryang Baek 65949dbcbc Merge pull request #11012 from open-webui/dependabot/pip/backend/dev/uvicorn-standard--0.34.0
chore(deps): bump uvicorn[standard] from 0.30.6 to 0.34.0 in /backend
2025-02-28 18:43:47 -08:00
dependabot[bot] eb97e30b0a chore(deps): bump bcrypt from 4.2.0 to 4.3.0 in /backend
Bumps [bcrypt](https://github.com/pyca/bcrypt) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/pyca/bcrypt/blob/main/release.py)
- [Commits](https://github.com/pyca/bcrypt/compare/4.2.0...4.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-01 02:37:41 +00:00
dependabot[bot] 84518490c5 chore(deps): bump peewee from 3.17.8 to 3.17.9 in /backend
Bumps [peewee](https://github.com/coleifer/peewee) from 3.17.8 to 3.17.9.
- [Release notes](https://github.com/coleifer/peewee/releases)
- [Changelog](https://github.com/coleifer/peewee/blob/master/CHANGELOG.md)
- [Commits](https://github.com/coleifer/peewee/compare/3.17.8...3.17.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-01 02:37:36 +00:00
dependabot[bot] dd73ecbdc9 chore(deps): bump black from 24.8.0 to 25.1.0 in /backend
Bumps [black](https://github.com/psf/black) from 24.8.0 to 25.1.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.8.0...25.1.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-01 02:37:32 +00:00
dependabot[bot] 588a179b7f chore(deps): bump uvicorn[standard] from 0.30.6 to 0.34.0 in /backend
Bumps [uvicorn[standard]](https://github.com/encode/uvicorn) from 0.30.6 to 0.34.0.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.30.6...0.34.0)

---
updated-dependencies:
- dependency-name: uvicorn[standard]
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-01 02:37:27 +00:00
dependabot[bot] 244915cd1e chore(deps): bump sqlalchemy from 2.0.32 to 2.0.38 in /backend
Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.32 to 2.0.38.
- [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases)
- [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst)
- [Commits](https://github.com/sqlalchemy/sqlalchemy/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-01 02:37:24 +00:00
Timothy Jaeryang Baek 094d777e55 Merge pull request #11009 from amuwall/fix-typing-error
fix: typing error in replace_messages_variable func args
2025-02-28 18:34:24 -08:00
amuwall b430deab15 fix: fix typing error in args 2025-03-01 10:20:29 +08:00
Timothy Jaeryang Baek fe44e4d344 i18n(html): sync html language with i18n language 2025-02-28 16:06:28 -08:00
Timothy Jaeryang Baek 88c02d5a14 refac 2025-02-28 16:02:15 -08:00
Timothy Jaeryang Baek b75b891de1 Merge pull request #10983 from NovoNordisk-OpenSource/yvedeng/fix-redis-timeout-config
fix: add redis lock timeout config
2025-02-28 16:01:59 -08:00
Timothy Jaeryang Baek f1ce653830 Merge pull request #10992 from softue/i18n-floating-buttons
i18n floating buttons (en-US and pt-BR)
2025-02-28 15:59:49 -08:00
Rômulo Mendes Figueiredo c06484297b i18n: ask and explain floating buttons 2025-02-28 12:39:57 -03:00
Timothy Jaeryang Baek 331caad5b8 fix 2025-02-28 07:36:56 -08:00
Thomas Rehn 8b3fcfea13 feat: add alternative input configuration where Ctrl-Enter is required to send chat messages 2025-02-28 15:23:03 +01:00
Timothy Jaeryang Baek 05599e1bb5 Merge pull request #10967 from OrenZhang/i18n_zh_cn
i18n(zh-cn): add chinese translation
2025-02-28 03:21:51 -08:00
orenzhang bcdbc4fb8a i18n(zh-cn): add chinese translation 2025-02-28 17:47:27 +08:00
Yifang Deng 41a2b86e70 fix: add redis lock timeout config 2025-02-28 10:12:51 +01:00
Danny Liu f9292ab4d2 Merge branch 'dev' into logit_bias 2025-02-27 23:51:13 -08:00
dannyl1u f4bd094128 Merge remote-tracking branch 'origin' into logit_bias 2025-02-27 23:48:22 -08:00
dannyl1u 90aa29528c logit_bias: handle comma seperated values 2025-02-27 23:13:30 -08:00
dannyl1u 34e3cb6881 logit bias: update tooltip message 2025-02-27 23:13:09 -08:00
Timothy Jaeryang Baek d0ddb0637e enh: web embed bypass embedding and retrieval support 2025-02-27 16:34:05 -08:00
hurxxxx 2405809f92 Merge branch 'main' into fix/update-msal-browser-version 2025-02-28 08:44:06 +09:00
hurxxxx 77579f5509 chore: cleanup 2025-02-28 08:42:02 +09:00
Timothy Jaeryang Baek 5e33955566 Merge pull request #10940 from TheMasterFX/update-de-DE-translation
i18n: Update de-DE translation
2025-02-27 15:17:59 -08:00
Timothy Jaeryang Baek 73c0262345 Merge branch 'dev' into update-de-DE-translation 2025-02-27 15:17:51 -08:00
Timothy Jaeryang Baek a3ea924f0a Merge pull request #10941 from TiancongLx/dev
i18n: update zh-TW
2025-02-27 15:17:03 -08:00
Tiancong Li 01435997b9 i18n: update zh-TW 2025-02-28 06:29:53 +08:00
Timmo 90fe437e29 i18n: Update de-DE translation - fix typos 2025-02-27 23:23:05 +01:00
Timmo 43077b82b7 i18n: Update de-DE translation 2025-02-27 23:09:02 +01:00
Timothy Jaeryang Baek 1b56a8f3cb Merge pull request #10864 from kurtdami/perplexity_integration
feat: add perplexity integration to web search
2025-02-27 13:51:03 -08:00
Timothy Jaeryang Baek 1d27402dd0 Merge pull request #10910 from saraangelmurphy/awsworkloadidentity
feat: add AWS workload identity support
2025-02-27 13:50:36 -08:00
Timothy Jaeryang Baek 6f7161acf8 chore: format 2025-02-27 13:04:39 -08:00
Timothy Jaeryang Baek 54e3ca8e6c refac: rm confusing defaults 2025-02-27 13:03:28 -08:00
Timothy Jaeryang Baek 95cadaca72 Merge pull request #10933 from open-webui/dev
0.5.18
2025-02-27 12:00:52 -08:00
Timothy Jaeryang Baek e26c443815 0.5.18 2025-02-27 12:00:23 -08:00
Timothy Jaeryang Baek 046f1fb30a Merge pull request #10927 from open-webui/dev
0.5.18
2025-02-27 11:50:08 -08:00
Timothy Jaeryang Baek 63d825692e doc: changelog 2025-02-27 11:49:59 -08:00
Timothy Jaeryang Baek 70667d5cc8 refac 2025-02-27 11:45:25 -08:00
Timothy Jaeryang Baek 2ac0eae99e Merge pull request #10925 from SleepyYui/fix-10905
fix: Fix OpenAI connection not actually deleting
2025-02-27 11:44:20 -08:00
Timothy Jaeryang Baek 1dd442f38b Merge pull request #10919 from df-cgdm/main
**fix** Resolve a bug in /api/models/base which was not returning openai models when ENABLE_FORWARD_USER_INFO_HEADERS is true
2025-02-27 11:42:51 -08:00
Yui 94c19f73b0 add config update when deleting openai model 2025-02-27 20:41:30 +01:00
Timothy Jaeryang Baek ed0b9be4e3 fix 2025-02-27 11:39:59 -08:00
Timothy Jaeryang Baek 866d02bbae refac 2025-02-27 11:39:00 -08:00
Timothy Jaeryang Baek f7c5cd486b Merge pull request #10908 from barnabehvrd/main
i18n: Updated translations for fr-FR
2025-02-27 11:37:58 -08:00
Timothy Jaeryang Baek 660a44c918 fix: insecure connection over LAN 2025-02-27 11:37:44 -08:00
Didier FOURNOUT b6873153c1 resolve a bug in /api/models/base not returning openai models when ENABLE_FORWARD_USER_INFO_HEADERS is true 2025-02-27 19:32:15 +00:00
df-cgdm 24a2e84692 Merge branch 'open-webui:main' into main 2025-02-27 19:13:00 +00:00
Sara Angel-Murphy 5e873bc643 feat: add AWS workload identity support 2025-02-27 13:12:54 -05:00
v0ltis f643182eb0 Updated french translation 2025-02-27 19:04:53 +01:00
hurxxxx c5cfcc1229 chore: cleanup 2025-02-27 23:16:52 +09:00
hurxxxx 203dfdedf1 Update deprecated msal-browser version to the latest 2025-02-27 23:12:09 +09:00
hurxxxx febc9ab885 Merge remote-tracking branch 'upstream/main' 2025-02-27 21:08:20 +09:00
Timothy Jaeryang Baek 38fb9d5fd8 chore: jspdf depedency 2025-02-27 03:54:16 -08:00
Timothy Jaeryang Baek 15485e7c5d Merge pull request #10469 from open-webui/dev
0.5.17
2025-02-27 03:46:08 -08:00
Timothy Jaeryang Baek 8241fa2e12 doc: changelog 2025-02-27 03:45:33 -08:00
Timothy Jaeryang Baek 37a7dd6d33 chore: format 2025-02-27 02:18:44 -08:00
Timothy Jaeryang Baek 85b7ec9387 Merge pull request #10873 from TiancongLx/dev
i18n: update zh-TW
2025-02-27 02:17:33 -08:00
Tiancong Li b838a1df99 i18n: update zh-TW 2025-02-27 17:57:10 +08:00
tidely d7b18b662b chore: use logging.getLevelNamesMapping()
Use `logging.getLevelNamesMapping()` for getting all valid logging levels. This also allows adding new ones using `logging.addLevel()`. This feature was [added](https://docs.python.org/3.11/library/logging.html#logging.getLevelNamesMapping) in Python 3.11 which is above the minimum supported Python version for open-webui.
2025-02-27 11:19:27 +02:00
kurtdami b061775932 feat: add perplexity integration to web search 2025-02-27 00:30:48 -08:00
Timothy Jaeryang Baek 8c4d967ef4 refac: evaluations settings ui 2025-02-26 23:58:43 -08:00
Timothy Jaeryang Baek ce7cf62a55 refac: dedup 2025-02-26 23:51:39 -08:00
Timothy Jaeryang Baek 0a4dbf7cf0 refac 2025-02-26 23:41:09 -08:00
Timothy Jaeryang Baek fcbdfbd744 refac 2025-02-26 23:35:09 -08:00
Timothy Jaeryang Baek 1ae702b8a6 Merge pull request #10860 from open-webui/audit-log-dev
feat: add audit logging feature
2025-02-26 23:19:37 -08:00
Timothy Jaeryang Baek 2d379cb35f chore: pyproject 2025-02-26 23:19:19 -08:00
Timothy Jaeryang Baek 76e90d9f3f Merge pull request #10436 from victorstevansuse/feat/audits
feat: add audit logging feature
2025-02-26 23:15:10 -08:00
Timothy Jaeryang Baekandsilentoplayz da8c42ee49 feat: Sort models by name in ascending/descending order
Co-Authored-By: silentoplayz <50341825+silentoplayz@users.noreply.github.com>
2025-02-26 23:12:46 -08:00
Timothy Jaeryang Baek ddb30589e3 chore: format
HIDE MODELS
2025-02-26 22:18:18 -08:00
Timothy Jaeryang Baek ef734833a5 fix 2025-02-26 21:22:51 -08:00
Timothy Jaeryang Baek 40ddd172e0 refac: web search settings ui 2025-02-26 15:56:45 -08:00
Timothy Jaeryang Baek 57010901e6 enh: bypass embedding and retrieval 2025-02-26 15:42:19 -08:00
Timothy Jaeryang Baek 1c2e36f1b7 refac 2025-02-26 13:59:08 -08:00
Timothy Jaeryang Baek 34aeaaf020 refac 2025-02-26 13:54:26 -08:00
Timothy Jaeryang Baek fa91d83ac3 refac: documents settings ui 2025-02-26 13:48:56 -08:00
Timothy Jaeryang Baek 78a8ef8e66 refac: audio file handling 2025-02-26 13:09:52 -08:00
Timothy Jaeryang Baek 46ac6f2b29 fix 2025-02-26 12:53:07 -08:00
Timothy Jaeryang Baek 33d3558ca9 Merge pull request #10817 from NovoNordisk-OpenSource/ivaroli/adding-json-as-supported-file-type
fix: Using the TextLoader instead of Tika for JSON files
2025-02-26 12:49:29 -08:00
Timothy Jaeryang Baek c1c92b0c03 Merge pull request #10820 from oyve/dev
i18n: Update translation.json for nb-NO
2025-02-26 12:49:02 -08:00
Øyvind 618307f35b Update translation.json
Fill all empty translation
2025-02-26 16:29:47 +01:00
Ívar Óli Sigurðsson c5a09cdd21 adding a comma 2025-02-26 15:27:03 +01:00
Ívar Óli Sigurðsson 661711164a Adding json as a known source for Tika 2025-02-26 15:11:21 +01:00
Timothy Jaeryang Baek 80efaaffdd Merge pull request #10810 from panda44312/patch-1
i18n: Update zh-cn translation
2025-02-26 04:29:41 -08:00
Panda 46f2dc0d11 Update zh-cn 2025-02-26 12:24:52 +01:00
Timothy Jaeryang Baek 674d6e08fc Merge pull request #10809 from TobiasGoerke/feat/update_timestamp_asynchronously
feat: update get_current_user to refresh last active timestamp asynchronously
2025-02-26 02:38:06 -08:00
Tobias Goerke 76891f4760 feat: update get_current_user to refresh last active timestamp asynchronously 2025-02-26 10:53:47 +01:00
Timothy Jaeryang Baek 20f0b02b38 Merge pull request #10755 from softue/check-if-enable-ldap-chroma
Conditional import of ldap3 and chromadb
2025-02-25 16:02:40 -08:00
Timothy Jaeryang Baek bef7a532cc refac 2025-02-25 16:01:29 -08:00
Timothy Jaeryang Baek ced508c912 Merge pull request #10783 from silentoplayz/share-fix
fix: community sharing for prompts and tools
2025-02-25 15:35:11 -08:00
Timothy Jaeryang Baek c883368842 Merge pull request #10786 from silentoplayz/confirm-clear-for-memories
feat: Confirmation prompt on 'Clear' for memories
2025-02-25 15:34:14 -08:00
Silentoplayz 8097b6a3ff Update ManageModal.svelte
no confirmation when no memories exist
2025-02-25 18:00:59 -05:00
Silentoplayz 7cfbf6489a Update ManageModal.svelte
feat: Add ConfirmDialog to 'Clear Memory' button
2025-02-25 17:53:13 -05:00
Silentoplayz 11967a780d Share fix
Fix
2025-02-25 17:05:05 -05:00
Timothy Jaeryang Baek 6cf0ceedba refac 2025-02-25 11:11:36 -08:00
Timothy Jaeryang Baek ec72dcba75 Merge pull request #10742 from hjer/improve-swedish-translation
i18n: Add and improve Swedish translation
2025-02-25 10:54:19 -08:00
Timothy Jaeryang Baek 54d7bf8f28 Merge branch 'dev' into improve-swedish-translation 2025-02-25 10:54:08 -08:00
Timothy Jaeryang Baek 3be5e3129b Merge pull request #10752 from NovoNordisk-OpenSource/yvedeng/standardize-logging
refactor: replace print statements with logging
2025-02-25 10:53:02 -08:00
Rômulo Mendes Figueiredo 2298bfbd7e refac: conditional import of ldap3 2025-02-25 13:07:53 -03:00
Yifang Deng 0e5d5ecb81 refactor: replace print statements with logging for better error tracking 2025-02-25 15:53:55 +01:00
Rômulo Mendes Figueiredo b52a7e3543 refac: conditional import of chromadb 2025-02-25 11:35:36 -03:00
hjer df1238229c add and improve swedish translation 2025-02-25 13:58:09 +01:00
Timothy Jaeryang Baek eafefcf838 chore: bump 2025-02-25 01:46:57 -08:00
Timothy Jaeryang Baek 9f27d7710b chore: format 2025-02-25 01:46:08 -08:00
Timothy Jaeryang Baek 7c68a424f8 refac 2025-02-25 01:44:39 -08:00
Timothy Jaeryang Baek 5d3249848d refac: allow image only message via enter 2025-02-25 01:40:17 -08:00
Timothy Jaeryang Baek 7e2b2ec761 fix: openai usage display issue 2025-02-25 01:35:59 -08:00
Timothy Jaeryang Baek b964b175d4 refac 2025-02-25 01:03:15 -08:00
Timothy Jaeryang Baek 46c4da4864 enh: "stream" hook 2025-02-25 01:00:29 -08:00
df-cgdm 126045318f Merge branch 'open-webui:main' into main 2025-02-25 07:37:57 +00:00
Timothy Jaeryang Baek 205013da2f Merge pull request #10683 from grandtele/fix/openai-o1-legacy-roles
fix: restore compatibility for older o1 models (o1-mini, o1-preview)
2025-02-24 23:35:18 -08:00
Timothy Jaeryang Baek abf36c0645 Merge pull request #10711 from jasonpnnl/send-usage-to-pipeline-outlet
fix: Include usage in chatCompleted handler for pipeline outlet functions
2025-02-24 23:34:18 -08:00
Timothy Jaeryang Baek e06111a362 refac 2025-02-24 23:21:03 -08:00
Timothy Jaeryang Baek cd3904046d refac 2025-02-24 20:19:32 -08:00
Jason E. Jensen 89aaf64209 add optional usage to chatCompleted messages 2025-02-24 22:32:08 +00:00
Timothy Jaeryang Baek 95ae2aeef3 Merge pull request #10704 from TiancongLx/dev
i18n: update zh-TW
2025-02-24 12:11:27 -08:00
Tiancong Li cefe274f74 i18n: update zh-TW 2025-02-25 04:03:16 +08:00
Timothy Jaeryang Baek d8bc3098db Merge pull request #9918 from df-cgdm/main
feat: Add  X-OpenWebUI when forwarding to ollama servers
2025-02-24 11:55:04 -08:00
Timothy Jaeryang Baek 628b89009c Merge pull request #10690 from hurxxxx/feat/onedrive-file-attach
feat: OneDrive file picker integration
2025-02-24 11:52:37 -08:00
Timothy Jaeryang Baek 23ff2988bf Merge branch 'dev' into feat/onedrive-file-attach 2025-02-24 11:51:59 -08:00
Timothy Jaeryang Baek e0c199255f Merge pull request #10666 from EkaterinePapava/main
i18n: Update Georgian translation
2025-02-24 11:37:18 -08:00
hurxxxx b2422b3c8f i18n: onedrive related 2025-02-25 01:56:33 +09:00
hurxxxx 4cc3102758 feat: onedrive file picker integration 2025-02-25 01:47:07 +09:00
grand 8c020488dd * fix: restore compatibility for older o1 models (o1-mini, o1-preview) 2025-02-24 12:04:06 +01:00
Gunwoo Hur 0335d479f9 feat: add onedrive file picker 2025-02-24 17:27:37 +09:00
Ekaterine Papava dcaab9d6b5 Update Georgian translation 2025-02-24 09:07:45 +01:00
Timothy Jaeryang Baek 0ba45ad82b Merge pull request #10617 from grandtele/fix/openai-o1-o3-role
fix: replace "system" with "developer" for o1 and o3 models
2025-02-23 22:33:59 -08:00
Timothy Jaeryang Baek 802f8809dd fix: codeblock 2025-02-23 21:39:34 -08:00
Timothy Jaeryang Baek 7a63947b94 fix: tailwind v4 ios 16.4 compatibility issue 2025-02-23 18:41:33 -08:00
Timothy Jaeryang Baek 737b1723c7 refac 2025-02-23 14:12:41 -08:00
Timothy Jaeryang Baek e90e254575 Merge pull request #10605 from silentoplayz/shortcuts-modal-update
feat: Add more keyboard shortcuts
2025-02-23 13:48:55 -08:00
Timothy Jaeryang Baek f8ad74fc42 refac 2025-02-23 13:32:19 -08:00
Timothy Jaeryang Baek 6c862b8fbf Merge pull request #10619 from OrenZhang/feat_s3_virtual_path
feat(s3): support for s3 virtual path style
2025-02-23 13:27:22 -08:00
Timothy Jaeryang Baek 4b908ea626 Merge pull request #10607 from TiancongLx/dev
i18n: update zh-TW
2025-02-23 13:24:04 -08:00
Timothy Jaeryang Baek b77937dfad Merge pull request #10634 from sebdanielsson/handle-no-location-access
fix: Handle no location permission
2025-02-23 13:23:47 -08:00
Sebastian b00e14eb3f Handle no location permission 2025-02-23 20:27:55 +01:00
orenzhang 9748a030f7 Merge remote-tracking branch 'oui/dev' into feat_s3_virtual_path 2025-02-23 20:54:04 +08:00
orenzhang 1060d62add refactor(s3): code lint 2025-02-23 20:52:22 +08:00
orenzhang 4c3dd84b32 feat(s3): support for s3 virtual path style 2025-02-23 20:31:08 +08:00
grand 82657acdce * fix: adjust role mapping for o1 and o3 models in openai_o1_o3_handler 2025-02-23 13:20:45 +01:00
Tiancong Li c96cc831d5 i18n: update zh-TW 2025-02-23 14:29:51 +08:00
Silentoplayz e41a5278f7 Update ShortcutsModal.svelte
fix
2025-02-22 23:00:06 -05:00
Silentoplayz b5aa562039 Update ShortcutsModal.svelte
Added more shortcuts
2025-02-22 22:50:37 -05:00
Silentoplayz 5281aeb07b Merge branch 'dev' of https://github.com/silentoplayz/open-webui into dev 2025-02-22 21:17:56 -05:00
Timothy Jaeryang Baek 39717812ee Merge pull request #10572 from Kylapaallikko/dev
i18n: Update Finnish Translation
2025-02-22 14:31:24 -08:00
Timothy Jaeryang Baek 9f6e182400 Merge pull request #10573 from dom133/dev
i18n: Update Polish Translation
2025-02-22 14:30:27 -08:00
Timothy Jaeryang Baek e4ba8fea8a Merge pull request #10583 from hurxxxx/feat/show_at_model_suggestions_to_placeholder
feat: Show atSelectedModel suggestions in Placeholder
2025-02-22 14:29:52 -08:00
hurxxxx f80aea60c7 fix: Fix incorrect object property usage 2025-02-23 00:19:51 +09:00
hurxxxx a407558375 feat: Show suggestions when selecting the atModel. 2025-02-22 23:27:13 +09:00
dom133 65aa41ab81 i18n: Update Polish Translation 2025-02-22 13:10:30 +01:00
Kylapaallikko 6331963e66 Update translation.json 2025-02-22 13:34:35 +02:00
Timothy Jaeryang Baek 32fa8d5143 Merge pull request #10566 from oloflarsson/feat/async-pipes
feat: add optional support for async pipes
2025-02-22 02:47:08 -08:00
Timothy Jaeryang Baek 8f3fcdcf5c refac 2025-02-22 02:00:06 -08:00
Timothy Jaeryang Baek 794919e91d refac 2025-02-22 01:22:17 -08:00
Timothy Jaeryang Baek 667d26ca12 refac 2025-02-22 01:16:58 -08:00
Timothy Jaeryang Baek 50dec12072 refac 2025-02-21 22:15:22 -08:00
Timothy Jaeryang Baek 8fb6b03ddc Merge pull request #10555 from pwnless/dev
fix: ollama backend native tool calling
2025-02-21 21:09:51 -08:00
hopeless 7bfa29fa81 Update payload.py
Fixes ollama native tool calling because native tool calling content will be str '', and tool call processing will be completely ignored.
2025-02-22 12:13:14 +08:00
Timothy Jaeryang Baek 9bada6421e refac: code block image styling 2025-02-21 16:39:56 -08:00
Timothy Jaeryang Baekandharry zhou b14e75dd6c feat: added Trust Proxy Environment switch in Web Search admin settings tab.
Co-Authored-By: harry zhou <67385896+harryzhou2000@users.noreply.github.com>
2025-02-21 13:40:11 -08:00
Timothy Jaeryang Baek 6623583004 Merge pull request #10549 from jeannotdamoiseaux/fix/oauth-groups-default-typeerror
Fix: Ensure `user_oauth_groups` defaults to an empty list to prevent TypeError
2025-02-21 13:37:35 -08:00
Jeannot Damoiseaux d50098b622 Fix: Ensure user_oauth_groups defaults to an empty list to prevent TypeError
When the OAuth groups claim does not yield a list, `user_oauth_groups` was previously
set to None, causing a TypeError during membership checks. Changed this default to
an empty list (`[]`) to ensure the variable is always iterable, preventing errors
for non-admin users while logging in.

This fix ensures stability in the `update_user_groups` function.
2025-02-21 22:25:22 +01:00
Timothy Jaeryang Baek 642dcd4b70 fix: model import 2025-02-21 13:19:11 -08:00
Timothy Jaeryang Baek a7d8ed0c6d refac 2025-02-21 12:11:21 -08:00
Timothy Jaeryang Baek ab1b910d80 Merge pull request #10486 from Micca/feature/document_intelligence_support
Feat: Adding Support for Azure AI Document Intelligence for Content Extraction (Revised)
2025-02-21 10:56:18 -08:00
Timothy Jaeryang Baek a3c2ff61ed Merge pull request #10483 from KarlLee830/translate
i18n: Update Chinese Translation
2025-02-21 10:55:55 -08:00
Timothy Jaeryang Baek b111434fa9 Merge pull request #10488 from glimpse-0201/dev
fix:Quick selection tool lost
2025-02-21 10:55:44 -08:00
Timothy Jaeryang Baek 613a087387 refac 2025-02-21 10:55:03 -08:00
Timothy Jaeryang Baek b0a19a9801 Merge pull request #10501 from Synergyst/feature/openai-tts-custom-url-fix-dev
fix: Custom OpenAI-TTS URL to fetch actual voices and models
2025-02-21 10:52:41 -08:00
Timothy Jaeryang Baek 086a78f863 Merge pull request #10521 from Seniorsimo/add-stop-parameter
**fix** Added support for stop parameter in OpenAI to Ollama conversion
2025-02-21 10:51:49 -08:00
Timothy Jaeryang Baek b4c64a21b3 Merge pull request #10513 from mcelrath/dev
Support thinking/solution tags used by Openthinker
2025-02-21 10:49:45 -08:00
Simone fb3886cf04 Added support for stop parameter 2025-02-21 18:46:10 +01:00
Bob McElrath c9079d4b4d Merge branch 'open-webui:dev' into dev 2025-02-21 09:13:53 -05:00
Bob McElrath c4b441de65 Support thinking tags used by Openthinker 2025-02-21 09:12:34 -05:00
Synergyst f789ad59a9 Update audio.py
Removed original code that was commented out
2025-02-21 04:47:46 -06:00
Coleton M cdf620e6ee Update audio.py to fetch custom URL voices and models 2025-02-21 04:41:45 -06:00
huanght 2993332b38 fix:Quick selection tool lost 2025-02-21 16:10:11 +08:00
Olof Larsson ab1f2ae914 feat/async-pipes 2025-02-21 08:24:48 +01:00
Karl Lee 68e33b5386 i18n: Update Chinese Translation 2025-02-21 14:58:40 +08:00
Timothy Jaeryang Baek e31f680788 refac 2025-02-20 20:46:00 -08:00
Timothy Jaeryang Baek 1bf16ec23d Merge pull request #10462 from Seniorsimo/fix-ollama-stream-conversion
**fix** ollama to openai conversion on one shot streamed response
2025-02-20 13:30:29 -08:00
Timothy Jaeryang Baek 2697a4d7f8 Merge pull request #10460 from aleixdorca/dev
Update catalan translation.json
2025-02-20 13:30:00 -08:00
Timothy Jaeryang Baek 8629f6b0f3 Merge pull request #10466 from HuangruiChu/main
i18n: Update zh-Chinese translation
2025-02-20 13:29:49 -08:00
Richard (Huangrui) Chu 1332a0d381 Update zh-CN translation
1. remove the typo ”‘’"  for -1 表示无限制,正整数表示具体限制”
2. Token should be kept rather than translated as "标记"
3. Max Tokens (num_predict) should be "最大Token数量 (num_predict)"
4. "Enter Jupyter Token" here “Token" could be translated as ”令牌“ just as 	"JWT Token": "JWT 令牌", (line 582)
5. "TLS" which means "Transport Layer Security" should be translated to "传输层安全协议"
6. "Tokens To Keep On Context Refresh (num_keep)" "在语境刷新时需保留的 Token 数量"
7. change token to "Token" in the Chinese translation.
2025-02-20 16:07:32 -05:00
Simone a8859a8145 Fix on ollama to openai conversion - stream can return a single message with content 2025-02-20 21:25:32 +01:00
Timothy Jaeryang Baek 6fedd72e39 Merge pull request #10459 from open-webui/dev
0.5.16
2025-02-20 11:26:36 -08:00
Timothy Jaeryang Baek 472a291c24 doc: changelog 2025-02-20 11:25:17 -08:00
Aleix Dorca 08df5f6d7c Update catalan translation.json 2025-02-20 20:24:58 +01:00
Timothy Jaeryang Baek b124c15018 Merge pull request #10431 from EntropyYue/dev
i18n: Update Chinese translation
2025-02-20 11:19:18 -08:00
Timothy Jaeryang Baek 0a7a265ff5 Merge pull request #10445 from PkmX/pr-fix-web-search-proxy
fix: correctly interpret RAG_WEB_SEARCH_TRUST_ENV as bool
2025-02-20 11:16:47 -08:00
Timothy Jaeryang Baek 93d486d50e revert: faulty dedup code 2025-02-20 11:02:45 -08:00
PkmX af10aba805 fix: correctly interpret RAG_WEB_SEARCH_TRUST_ENV as bool 2025-02-21 01:46:17 +08:00
Victor Ribeiro 39c6ecb50d feat: add audit logger middleware and custom logger using loguru 2025-02-20 11:15:38 -03:00
hurxxxx f865a23a18 Merge remote-tracking branch 'upstream/main' 2025-02-20 23:08:47 +09:00
EntropyYue 89b4627e9a Update translation.json 2025-02-20 21:47:21 +08:00
EntropyYue e920f96b17 Update translation.json 2025-02-20 21:12:04 +08:00
Timothy Jaeryang Baek cf4bbcde14 Merge pull request #10421 from open-webui/dev
fix: workflow
2025-02-20 02:10:02 -08:00
Timothy Jaeryang Baek c882aacc23 fix: workflow 2025-02-20 02:09:44 -08:00
Timothy Jaeryang Baek 624f2ebcff Merge pull request #10420 from open-webui/dependabot/pip/pip-e8a89e233e
build(deps): bump the pip group across 2 directories with 1 update
2025-02-20 02:03:07 -08:00
dependabot[bot] eb218f6674 build(deps): bump the pip group across 2 directories with 1 update
Bumps the pip group with 1 update in the / directory: [python-jose](https://github.com/mpdavis/python-jose).
Bumps the pip group with 1 update in the /backend directory: [python-jose](https://github.com/mpdavis/python-jose).


Updates `python-jose` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/mpdavis/python-jose/releases)
- [Changelog](https://github.com/mpdavis/python-jose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mpdavis/python-jose/compare/3.3.0...3.4.0)

Updates `python-jose` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/mpdavis/python-jose/releases)
- [Changelog](https://github.com/mpdavis/python-jose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mpdavis/python-jose/compare/3.3.0...3.4.0)

---
updated-dependencies:
- dependency-name: python-jose
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: python-jose
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-20 10:02:35 +00:00
dannyl1u 8aa6415500 include logit_bias in form_data 2025-02-19 16:23:58 -08:00
dannyl1u 4a2a12fd21 feat: scaffolding for logit_bias 2025-02-19 10:33:49 -08:00
hurxxxx a381ffc585 revert 2025-02-18 23:17:06 +09:00
hurxxxx cdc13e9960 chore: Fix unnecessary whitespace issues 2025-02-18 23:15:57 +09:00
silentoplayz 1a2d81bc01 Update main.py
Change ASCII font for "Open WebUI" from "Standard" to "ANSI Shadow"
2025-02-14 12:48:33 -05:00
Fabio Polito 2419ef06a0 feat: docling support for document preprocessing 2025-02-14 12:08:03 +00:00
Didier FOURNOUT 06062568c7 black formatting 2025-02-13 16:12:46 +00:00
Didier FOURNOUT 6d62e71c34 Add x-Open-Webui headers for ollama + more for openai 2025-02-13 15:29:26 +00:00
Mazurek Michal 35f3824932 feat: Implement Document Intelligence as Content Extraction Engine 2025-02-07 13:44:47 +01:00
JT 40dea3fbe1 Merge branch 'dev' into main 2025-02-05 15:15:24 -08:00
jayteaftw 157c781b0a Merge branch 'main' of https://github.com/jayteaftw/open-webui 2025-02-05 14:07:59 -08:00
jayteaftw 6d2f87e904 Added server side Prefixing 2025-02-05 14:03:16 -08:00
JT 81102f4be2 Merge branch 'open-webui:main' into main 2025-02-04 13:06:04 -08:00
jvinolus 7b8e5d4e7c Fixed errors and added more support 2025-02-04 13:04:36 -08:00
Matteo Sirri 22c100bb6b feat: add contributing guide 2025-02-03 16:45:25 +00:00
Matteo Sirri e6715ce8b8 docs: fix readme 2025-02-03 16:43:59 +00:00
Matteo Sirri 65443a3a66 feat: initial commit 2025-02-03 16:35:46 +00:00
jvinolus 47b8412695 Initialize support for prefixing embeddings 2025-01-15 17:05:04 -08:00
272 changed files with 19981 additions and 7093 deletions
-80
View File
@@ -1,80 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
# Bug Report
## Important Notes
- **Before submitting a bug report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project.
- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours.
- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
Note: Please remove the notes above when submitting your post. Thank you for your understanding and support!
---
## Installation Method
[Describe the method you used to install the project, e.g., git clone, Docker, pip, etc.]
## Environment
- **Open WebUI Version:** [e.g., v0.3.11]
- **Ollama (if applicable):** [e.g., v0.2.0, v0.1.32-rc1]
- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04]
- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0]
**Confirmation:**
- [ ] I have read and followed all the instructions provided in the README.md.
- [ ] I am on the latest version of both Open WebUI and Ollama.
- [ ] I have included the browser console logs.
- [ ] I have included the Docker container logs.
- [ ] I have provided the exact steps to reproduce the bug in the "Steps to Reproduce" section below.
## Expected Behavior:
[Describe what you expected to happen.]
## Actual Behavior:
[Describe what actually happened.]
## Description
**Bug Summary:**
[Provide a brief but clear summary of the bug]
## Reproduction Details
**Steps to Reproduce:**
[Outline the steps to reproduce the bug. Be as detailed as possible.]
## Logs and Screenshots
**Browser Console Logs:**
[Include relevant browser console logs, if applicable]
**Docker Container Logs:**
[Include relevant Docker container logs, if applicable]
**Screenshots/Screen Recordings (if applicable):**
[Attach any relevant screenshots to help illustrate the issue]
## Additional Information
[Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.]
## Note
If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you!
+146
View File
@@ -0,0 +1,146 @@
name: Bug Report
description: Create a detailed bug report to help us improve Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
assignees: []
body:
- type: markdown
attributes:
value: |
# Bug Report
## 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.
- **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.
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
- type: checkboxes
id: issue-check
attributes:
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.
required: true
- label: I am using the latest version of Open WebUI.
required: true
- type: dropdown
id: installation-method
attributes:
label: Installation Method
description: How did you install Open WebUI?
options:
- Git Clone
- Pip Install
- Docker
- Other
validations:
required: true
- type: input
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.3.11)
validations:
required: true
- type: input
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
validations:
required: false
- type: input
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
description: Ensure the following prerequisites have been met.
options:
- label: I have read and followed all instructions in `README.md`.
required: true
- label: I am using the latest version of **both** Open WebUI and Ollama.
required: true
- label: I have included the browser console logs.
required: true
- label: I have included the Docker container logs.
required: true
- label: I have listed steps to reproduce the bug in detail.
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: Describe what should have happened.
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: Describe what actually happened.
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See the error message '...'
validations:
required: true
- type: textarea
id: logs-screenshots
attributes:
label: Logs & Screenshots
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional Information
description: Provide any extra details that may assist in understanding the issue.
validations:
required: false
- type: markdown
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.
Thank you for contributing to Open WebUI!
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
-35
View File
@@ -1,35 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
# Feature Request
## Important Notes
- **Before submitting a report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project.
- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours.
- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
Note: Please remove the notes above when submitting your post. Thank you for your understanding and support!
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,64 @@
name: Feature Request
description: Suggest an idea for this project
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
## Important Notes
### Before submitting
Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
This will help us efficiently focus on improving the project.
### Collaborate respectfully
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
Remember:
- Open WebUI is a **volunteer-driven project**
- It's managed by a **single maintainer**
- It's supported by contributors who also have **full-time jobs**
We appreciate your time and ask that you **respect ours**.
### Contributing
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
### Bug reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
- type: checkboxes
id: existing-issue
attributes:
label: Check Existing Issues
description: Please confirm that you've checked for existing similar requests
options:
- label: I have searched the existing issues and discussions.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
placeholder: "Ex. I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Desired Solution you'd like
description: Clearly describe what you want to happen.
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
+3 -3
View File
@@ -9,9 +9,9 @@
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests for validating the changes?
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following:
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
@@ -22,7 +22,7 @@
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.)
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
+3 -3
View File
@@ -14,7 +14,7 @@ env:
jobs:
build-main-image:
runs-on: ubuntu-latest
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
@@ -111,7 +111,7 @@ jobs:
retention-days: 1
build-cuda-image:
runs-on: ubuntu-latest
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
@@ -211,7 +211,7 @@ jobs:
retention-days: 1
build-ollama-image:
runs-on: ubuntu-latest
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
- uses: actions/setup-python@v5
with:
python-version: 3.11
+114
View File
@@ -5,6 +5,120 @@ 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.0] - 2025-03-31
### Added
- 🧩 **External Tool Server Support via OpenAPI**: Connect Open WebUI to any OpenAPI-compatible REST server instantly—offering immediate integration with thousands of developer tools, SDKs, and SaaS systems for powerful extensibility. Learn more: https://github.com/open-webui/openapi-servers
- 🛠️ **MCP Server Support via MCPO**: You can now convert and expose your internal MCP tools as interoperable OpenAPI HTTP servers within Open WebUI for seamless, plug-n-play AI toolchain creation. Learn more: https://github.com/open-webui/mcpo
- 📨 **/messages Chat API Endpoint Support**: For power users building external AI systems, new endpoints allow precise control of messages asynchronously—feed long-running external responses into Open WebUI chats without coupling with the frontend.
- 📝 **Client-Side PDF Generation**: PDF exports are now generated fully client-side for drastically improved output quality—perfect for saving conversations or documents.
- 💼 **Enforced Temporary Chats Mode**: Admins can now enforce temporary chat sessions by default to align with stringent data retention and compliance requirements.
- 🌍 **Public Resource Sharing Permission Controls**: Fine-grained user group permissions now allow enabling/disabling public sharing for models, knowledge, prompts, and tools—ideal for privacy, team control, and internal deployments.
- 📦 **Custom pip Options for Tools/Functions**: You can now specify custom pip installation options with "PIP_OPTIONS", "PIP_PACKAGE_INDEX_OPTIONS" environment variables—improving compatibility, support for private indexes, and better control over Python environments.
- 🔢 **Editable Message Counter**: You can now double-click the message count number and jump straight to editing the index—quickly navigate complex chats or regenerate specific messages precisely.
- 🧠 **Embedding Prefix Support Added**: Add custom prefixes to your embeddings for instruct-style tokens, enabling stronger model alignment and more consistent RAG performance.
- 🙈 **Ability to Hide Base Models**: Optionally hide base models from the UI, helping users streamline model visibility and limit access to only usable endpoints..
- 📚 **Docling Content Extraction Support**: Open WebUI now supports Docling as a content extraction engine, enabling smarter and more accurate parsing of complex file formats—ideal for advanced document understanding and Retrieval-Augmented Generation (RAG) workflows.
- 🗃️ **Redis Sentinel Support Added**: Enhance deployment redundancy with support for Redis Sentinel for highly available, failover-safe Redis-based caching or pub/sub.
- 📚 **JSON Schema Format for Ollama**: Added support for defining the format using JSON schema in Ollama-compatible models, improving flexibility and validation of model outputs.
- 🔍 **Chat Sidebar Search "Clear” Button**: Quickly clear search filters in chat sidebar using the new ✖️ button—streamline your chat navigation with one click.
- 🗂️ **Auto-Focus + Enter Submit for Folder Name**: When creating a new folder, the system automatically enters rename mode with name preselected—simplifying your org workflow.
- 🧱 **Markdown Alerts Rendering**: Blockquotes with syntax hinting (e.g. ⚠️, ℹ️, ✅) now render styled Markdown alert banners, making messages and documentation more visually structured.
- 🔁 **Hybrid Search Runs in Parallel Now**: Hybrid (BM25 + embedding) search components now run in parallel—dramatically reducing response times and speeding up document retrieval.
- 📋 **Cleaner UI for Tool Call Display**: Optimized the visual layout of called tools inside chat messages for better clarity and reduced visual clutter.
- 🧪 **Playwright Timeout Now Configurable**: Default timeout for Playwright processes is now shorter and adjustable via environment variables—making web scraping more robust and tunable to environments.
- 📈 **OpenTelemetry Support for Observability**: Open WebUI now integrates with OpenTelemetry, allowing you to connect with tools like Grafana, Jaeger, or Prometheus for detailed performance insights and real-time visibility—entirely opt-in and fully self-hosted. Even if enabled, no data is ever sent to us, ensuring your privacy and ownership over all telemetry data.
- 🛠 **General UI Enhancements & UX Polish**: Numerous refinements across sidebar, code blocks, modal interactions, button alignment, scrollbar visibility, and folder behavior improve overall fluidity and usability of the interface.
- 🧱 **General Backend Refactoring**: Numerous backend components have been refactored to improve stability, maintainability, and performance—ensuring a more consistent and reliable system across all features.
- 🌍 **Internationalization Language Support Updates**: Added Estonian and Galician languages, improved Spanish (fully revised), Traditional Chinese, Simplified Chinese, Turkish, Catalan, Ukrainian, and German for a more localized and inclusive interface.
### Fixed
- 🧑‍💻 **Firefox Input Height Bug**: Text input in Firefox now maintains proper height, ensuring message boxes look consistent and behave predictably.
- 🧾 **Tika Blank Line Bug**: PDFs processed with Apache Tika 3.1.0.0 no longer introduce excessive blank lines—improving RAG output quality and visual cleanliness.
- 🧪 **CSV Loader Encoding Issues**: CSV files with unknown encodings now automatically detect character sets, resolving import errors in non-UTF-8 datasets.
- ✅ **LDAP Auth Config Fix**: Path to certificate file is now optional for LDAP setups, fixing authentication trouble for users without preconfigured cert paths.
- 📥 **File Deletion in Bypass Mode**: Resolved issue where files couldn’t be deleted from knowledge when “bypass embedding” mode was enabled.
- 🧩 **Hybrid Search Result Sorting & Deduplication Fixed**: Fixed citation and sorting issues in RAG hybrid and reranker modes, ensuring retrieved documents are shown in correct order per score.
- 🧷 **Model Export/Import Broken for a Single Model**: Fixed bug where individual models couldn’t be exported or re-imported, restoring full portability.
- 📫 **Auth Redirect Fix**: Logged-in users are now routed properly without unnecessary login prompts when already authenticated.
### Changed
- 🧠 **Prompt Autocompletion Disabled By Default**: Autocomplete suggestions while typing are now disabled unless explicitly re-enabled in user preferences—reduces distractions while composing prompts for advanced users.
- 🧾 **Normalize Citation Numbering**: Source citations now properly begin from "1" instead of "0"—improving consistency and professional presentation in AI outputs.
- 📚 **Improved Error Handling from Pipelines**: Pipelines now show the actual returned error message from failed tasks rather than generic "Connection closed"—making debugging far more user-friendly.
### Removed
- 🧾 **ENABLE_AUDIT_LOGS Setting Removed**: Deprecated setting “ENABLE_AUDIT_LOGS” has been fully removed—now controlled via “AUDIT_LOG_LEVEL” instead.
## [0.5.20] - 2025-03-05
### Added
- **⚡ Toggle Code Execution On/Off**: You can now enable or disable code execution, providing more control over security, ensuring a safer and more customizable experience.
### Fixed
- **📜 Pinyin Keyboard Enter Key Now Works Properly**: Resolved an issue where the Enter key for Pinyin keyboards was not functioning as expected, ensuring seamless input for Chinese users.
- **🖼️ Web Manifest Loading Issue Fixed**: Addressed inconsistencies with 'site.webmanifest', guaranteeing proper loading and representation of the app across different browsers and devices.
- **📦 Non-Root Container Issue Resolved**: Fixed a critical issue where the UI failed to load correctly in non-root containers, ensuring reliable deployment in various environments.
## [0.5.19] - 2025-03-04
### Added
- **📊 Logit Bias Parameter Support**: Fine-tune conversation dynamics by adjusting the Logit Bias parameter directly in chat settings, giving you more control over model responses.
- **⌨️ Customizable Enter Behavior**: You can now configure Enter to send messages only when combined with Ctrl (Ctrl+Enter) via Settings > Interface, preventing accidental message sends.
- **📝 Collapsible Code Blocks**: Easily collapse long code blocks to declutter your chat, making it easier to focus on important details.
- **🏷️ Tag Selector in Model Selector**: Quickly find and categorize models with the new tag filtering system in the Model Selector, streamlining model discovery.
- **📈 Experimental Elasticsearch Vector DB Support**: Now supports Elasticsearch as a vector database, offering more flexibility for data retrieval in Retrieval-Augmented Generation (RAG) workflows.
- **⚙️ General Reliability Enhancements**: Various stability improvements across the WebUI, ensuring a smoother, more consistent experience.
- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages.
### Fixed
- **🔄 "Stream" Hook Activation**: Fixed an issue where the "Stream" hook only worked when globally enabled, ensuring reliable real-time filtering.
- **📧 LDAP Email Case Sensitivity**: Resolved an issue where LDAP login failed due to email case sensitivity mismatches, improving authentication reliability.
- **💬 WebSocket Chat Event Registration**: Fixed a bug preventing chat event listeners from being registered upon sign-in, ensuring real-time updates work properly.
## [0.5.18] - 2025-02-27
### Fixed
- **🌐 Open WebUI Now Works Over LAN in Insecure Context**: Resolved an issue preventing Open WebUI from functioning when accessed over a local network in an insecure context, ensuring seamless connectivity.
- **🔄 UI Now Reflects Deleted Connections Instantly**: Fixed an issue where deleting a connection did not update the UI in real time, ensuring accurate system state visibility.
- **🛠️ Models Now Display Correctly with ENABLE_FORWARD_USER_INFO_HEADERS**: Addressed a bug where models were not visible when ENABLE_FORWARD_USER_INFO_HEADERS was set, restoring proper model listing.
## [0.5.17] - 2025-02-27
### Added
- **🚀 Instant Document Upload with Bypass Embedding & Retrieval**: Admins can now enable "Bypass Embedding & Retrieval" in Admin Settings > Documents, significantly speeding up document uploads and ensuring full document context is retained without chunking.
- **🔎 "Stream" Hook for Real-Time Filtering**: The new "stream" hook allows dynamic real-time message filtering. Learn more in our documentation (https://docs.openwebui.com/features/plugin/functions/filter).
- **☁️ OneDrive Integration**: Early support for OneDrive storage integration has been introduced, expanding file import options.
- **📈 Enhanced Logging with Loguru**: Backend logging has been improved with Loguru, making debugging and issue tracking far more efficient.
- **⚙️ General Stability Enhancements**: Backend and frontend refactoring improves performance, ensuring a smoother and more reliable user experience.
- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages.
### Fixed
- **🔄 Reliable Model Imports from the Community Platform**: Resolved import failures, allowing seamless integration of community-shared models without errors.
- **📊 OpenAI Usage Statistics Restored**: Fixed an issue where OpenAI usage metrics were not displaying correctly, ensuring accurate tracking of usage data.
- **🗂️ Deduplication for Retrieved Documents**: Documents retrieved during searches are now intelligently deduplicated, meaning no more redundant results—helping to keep information concise and relevant.
### Changed
- **📝 "Full Context Mode" Renamed for Clarity**: The "Full Context Mode" toggle in Web Search settings is now labeled "Bypass Embedding & Retrieval" for consistency across the UI.
## [0.5.16] - 2025-02-20
### Fixed
- **🔍 Web Search Retrieval Restored**: Resolved a critical issue that broke web search retrieval by reverting deduplication changes, ensuring complete and accurate search results once again.
## [0.5.15] - 2025-02-20
### Added
+1 -1
View File
@@ -132,7 +132,7 @@ RUN if [ "$USE_OLLAMA" = "true" ]; then \
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install uv && \
RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
+225 -38
View File
@@ -3,13 +3,13 @@ import logging
import os
import shutil
import base64
import redis
from datetime import datetime
from pathlib import Path
from typing import Generic, Optional, TypeVar
from urllib.parse import urlparse
import chromadb
import requests
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
@@ -18,6 +18,9 @@ from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENV,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR,
OFFLINE_MODE,
OPEN_WEBUI_DIR,
@@ -27,6 +30,7 @@ from open_webui.env import (
log,
)
from open_webui.internal.db import Base, get_db
from open_webui.utils.redis import get_redis_connection
class EndpointFilter(logging.Filter):
@@ -44,7 +48,7 @@ logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
# Function to run the alembic migrations
def run_migrations():
print("Running migrations")
log.info("Running migrations")
try:
from alembic import command
from alembic.config import Config
@@ -57,7 +61,7 @@ def run_migrations():
command.upgrade(alembic_cfg, "head")
except Exception as e:
print(f"Error: {e}")
log.exception(f"Error running migrations: {e}")
run_migrations()
@@ -249,9 +253,17 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
def __init__(self):
def __init__(
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
):
super().__setattr__("_state", {})
if redis_url:
super().__setattr__(
"_redis",
get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
)
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
@@ -260,7 +272,31 @@ class AppConfig:
self._state[key].value = value
self._state[key].save()
if self._redis:
redis_key = f"open-webui:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
def __getattr__(self, key):
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
redis_key = f"open-webui:config:{key}"
redis_value = self._redis.get(redis_key)
if redis_value is not None:
try:
decoded_value = json.loads(redis_value)
# Update the in-memory value if different
if self._state[key].value != decoded_value:
self._state[key].value = decoded_value
log.info(f"Updated {key} from Redis: {decoded_value}")
except json.JSONDecodeError:
log.error(f"Invalid JSON format in Redis for {key}: {redis_value}")
return self._state[key].value
@@ -588,6 +624,17 @@ load_oauth_providers()
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"):
if file_path.is_file():
target_path = STATIC_DIR / file_path.relative_to(
(FRONTEND_BUILD_DIR / "static")
)
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copyfile(file_path, target_path)
except Exception as e:
logging.error(f"An error occurred: {e}")
frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png"
if frontend_favicon.exists():
@@ -660,11 +707,7 @@ if CUSTOM_NAME:
# LICENSE_KEY
####################################
LICENSE_KEY = PersistentConfig(
"LICENSE_KEY",
"license.key",
os.environ.get("LICENSE_KEY", ""),
)
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
####################################
# STORAGE PROVIDER
@@ -678,6 +721,10 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None)
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
S3_USE_ACCELERATE_ENDPOINT = (
os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "False").lower() == "true"
)
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None)
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
@@ -692,16 +739,16 @@ AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None)
# File Upload DIR
####################################
UPLOAD_DIR = f"{DATA_DIR}/uploads"
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
UPLOAD_DIR = DATA_DIR / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
####################################
# Cache DIR
####################################
CACHE_DIR = f"{DATA_DIR}/cache"
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
CACHE_DIR = DATA_DIR / "cache"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
####################################
@@ -933,6 +980,35 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
@@ -953,6 +1029,11 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower()
== "true"
)
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
)
@@ -975,12 +1056,19 @@ DEFAULT_USER_PERMISSIONS = {
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"sharing": {
"public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING,
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
},
"features": {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
@@ -1045,6 +1133,12 @@ ENABLE_MESSAGE_RATING = PersistentConfig(
os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true",
)
ENABLE_USER_WEBHOOKS = PersistentConfig(
"ENABLE_USER_WEBHOOKS",
"ui.enable_user_webhooks",
os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true",
)
def validate_cors_origins(origins):
for origin in origins:
@@ -1094,7 +1188,7 @@ try:
banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]"))
banners = [BannerModel(**banner) for banner in banners]
except Exception as e:
print(f"Error loading WEBUI_BANNERS: {e}")
log.exception(f"Error loading WEBUI_BANNERS: {e}")
banners = []
WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners)
@@ -1266,7 +1360,7 @@ Strictly return in JSON format:
ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig(
"ENABLE_AUTOCOMPLETE_GENERATION",
"task.autocomplete.enable",
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true",
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true",
)
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
@@ -1370,6 +1464,11 @@ Responses from models: {{responses}}"""
# Code Interpreter
####################################
ENABLE_CODE_EXECUTION = PersistentConfig(
"ENABLE_CODE_EXECUTION",
"code_execution.enable",
os.environ.get("ENABLE_CODE_EXECUTION", "True").lower() == "true",
)
CODE_EXECUTION_ENGINE = PersistentConfig(
"CODE_EXECUTION_ENGINE",
@@ -1498,21 +1597,27 @@ VECTOR_DB = os.environ.get("VECTOR_DB", "chroma")
# Chroma
CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db"
CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT)
CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE)
CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "")
CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000"))
CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "")
CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get("CHROMA_CLIENT_AUTH_CREDENTIALS", "")
# Comma-separated list of header=value pairs
CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "")
if CHROMA_HTTP_HEADERS:
CHROMA_HTTP_HEADERS = dict(
[pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")]
if VECTOR_DB == "chroma":
import chromadb
CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT)
CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE)
CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "")
CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000"))
CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "")
CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get(
"CHROMA_CLIENT_AUTH_CREDENTIALS", ""
)
else:
CHROMA_HTTP_HEADERS = None
CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# Comma-separated list of header=value pairs
CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "")
if CHROMA_HTTP_HEADERS:
CHROMA_HTTP_HEADERS = dict(
[pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")]
)
else:
CHROMA_HTTP_HEADERS = None
CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus
@@ -1527,11 +1632,24 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", True)
OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False)
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true"
OPENSEARCH_CERT_VERIFY = (
os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true"
)
OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None)
OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None)
# ElasticSearch
ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200")
ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None)
ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None)
ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None)
ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None)
ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None)
SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None)
ELASTICSEARCH_INDEX_PREFIX = os.environ.get(
"ELASTICSEARCH_INDEX_PREFIX", "open_webui_collections"
)
# Pgvector
PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL)
if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
@@ -1566,6 +1684,18 @@ GOOGLE_DRIVE_API_KEY = PersistentConfig(
os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
)
ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
"ENABLE_ONEDRIVE_INTEGRATION",
"onedrive.enable",
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
ONEDRIVE_CLIENT_ID = PersistentConfig(
"ONEDRIVE_CLIENT_ID",
"onedrive.client_id",
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
)
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = PersistentConfig(
"CONTENT_EXTRACTION_ENGINE",
@@ -1579,9 +1709,40 @@ TIKA_SERVER_URL = PersistentConfig(
os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment
)
DOCLING_SERVER_URL = PersistentConfig(
"DOCLING_SERVER_URL",
"rag.docling_server_url",
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", ""),
)
DOCUMENT_INTELLIGENCE_KEY = PersistentConfig(
"DOCUMENT_INTELLIGENCE_KEY",
"rag.document_intelligence_key",
os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""),
)
BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
"BYPASS_EMBEDDING_AND_RETRIEVAL",
"rag.bypass_embedding_and_retrieval",
os.environ.get("BYPASS_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true",
)
RAG_TOP_K = PersistentConfig(
"RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3"))
)
RAG_TOP_K_RERANKER = PersistentConfig(
"RAG_TOP_K_RERANKER",
"rag.top_k_reranker",
int(os.environ.get("RAG_TOP_K_RERANKER", "3")),
)
RAG_RELEVANCE_THRESHOLD = PersistentConfig(
"RAG_RELEVANCE_THRESHOLD",
"rag.relevance_threshold",
@@ -1663,6 +1824,14 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig(
),
)
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get(
"RAG_EMBEDDING_PREFIX_FIELD_NAME", None
)
RAG_RERANKING_MODEL = PersistentConfig(
"RAG_RERANKING_MODEL",
"rag.reranking_model",
@@ -1714,7 +1883,7 @@ Respond to the user query using the provided context, incorporating inline citat
- Respond in the same language as the user's query.
- If the context is unreadable or of poor quality, inform the user and provide the best possible answer.
- If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding.
- **Only include inline citations using [source_id] when a <source_id> tag is explicitly provided in the context.**
- **Only include inline citations using [source_id] (e.g., [1], [2]) when a `<source_id>` tag is explicitly provided in the context.**
- Do not cite if the <source_id> tag is not provided in the context.
- Do not use XML tags in your response.
- Ensure citations are concise and directly related to the information provided.
@@ -1795,10 +1964,10 @@ RAG_WEB_SEARCH_ENGINE = PersistentConfig(
os.getenv("RAG_WEB_SEARCH_ENGINE", ""),
)
RAG_WEB_SEARCH_FULL_CONTEXT = PersistentConfig(
"RAG_WEB_SEARCH_FULL_CONTEXT",
"rag.web.search.full_context",
os.getenv("RAG_WEB_SEARCH_FULL_CONTEXT", "False").lower() == "true",
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL",
"rag.web.search.bypass_embedding_and_retrieval",
os.getenv("BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true",
)
# You can provide a list of your own websites to filter after performing a web search.
@@ -1886,6 +2055,12 @@ TAVILY_API_KEY = PersistentConfig(
os.getenv("TAVILY_API_KEY", ""),
)
TAVILY_EXTRACT_DEPTH = PersistentConfig(
"TAVILY_EXTRACT_DEPTH",
"rag.web.search.tavily_extract_depth",
os.getenv("TAVILY_EXTRACT_DEPTH", "basic"),
)
JINA_API_KEY = PersistentConfig(
"JINA_API_KEY",
"rag.web.search.jina_api_key",
@@ -1936,6 +2111,12 @@ EXA_API_KEY = PersistentConfig(
os.getenv("EXA_API_KEY", ""),
)
PERPLEXITY_API_KEY = PersistentConfig(
"PERPLEXITY_API_KEY",
"rag.web.search.perplexity_api_key",
os.getenv("PERPLEXITY_API_KEY", ""),
)
RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig(
"RAG_WEB_SEARCH_RESULT_COUNT",
"rag.web.search.result_count",
@@ -1957,7 +2138,7 @@ RAG_WEB_LOADER_ENGINE = PersistentConfig(
RAG_WEB_SEARCH_TRUST_ENV = PersistentConfig(
"RAG_WEB_SEARCH_TRUST_ENV",
"rag.web.search.trust_env",
os.getenv("RAG_WEB_SEARCH_TRUST_ENV", False),
os.getenv("RAG_WEB_SEARCH_TRUST_ENV", "False").lower() == "true",
)
PLAYWRIGHT_WS_URI = PersistentConfig(
@@ -1966,6 +2147,12 @@ PLAYWRIGHT_WS_URI = PersistentConfig(
os.environ.get("PLAYWRIGHT_WS_URI", None),
)
PLAYWRIGHT_TIMEOUT = PersistentConfig(
"PLAYWRIGHT_TIMEOUT",
"rag.web.loader.engine.playwright.timeout",
int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10")),
)
FIRECRAWL_API_KEY = PersistentConfig(
"FIRECRAWL_API_KEY",
"firecrawl.api_key",
@@ -2375,7 +2562,7 @@ LDAP_SEARCH_BASE = PersistentConfig(
LDAP_SEARCH_FILTERS = PersistentConfig(
"LDAP_SEARCH_FILTER",
"ldap.server.search_filter",
os.environ.get("LDAP_SEARCH_FILTER", ""),
os.environ.get("LDAP_SEARCH_FILTER", os.environ.get("LDAP_SEARCH_FILTERS", "")),
)
LDAP_USE_TLS = PersistentConfig(
+62 -21
View File
@@ -65,10 +65,8 @@ except Exception:
# LOGGING
####################################
log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
if GLOBAL_LOG_LEVEL in log_levels:
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
else:
GLOBAL_LOG_LEVEL = "INFO"
@@ -78,6 +76,7 @@ log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}")
if "cuda_error" in locals():
log.exception(cuda_error)
del cuda_error
log_sources = [
"AUDIO",
@@ -100,13 +99,12 @@ SRC_LOG_LEVELS = {}
for source in log_sources:
log_env_var = source + "_LOG_LEVEL"
SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper()
if SRC_LOG_LEVELS[source] not in log_levels:
if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping():
SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL
log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}")
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI":
WEBUI_NAME += " (Open WebUI)"
@@ -131,7 +129,6 @@ else:
except Exception:
PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"]
@@ -162,7 +159,6 @@ try:
except Exception:
changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode()
# Convert markdown content to HTML
html_content = markdown.markdown(changelog_content)
@@ -193,7 +189,6 @@ for version in soup.find_all("h2"):
changelog_json[version_number] = version_data
CHANGELOG = changelog_json
####################################
@@ -210,7 +205,6 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
####################################
# WEBUI_BUILD_HASH
####################################
@@ -245,7 +239,6 @@ if FROM_INIT_PY:
DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data"))
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static"))
FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts"))
@@ -257,7 +250,6 @@ if FROM_INIT_PY:
os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend")
).resolve()
####################################
# Database
####################################
@@ -322,7 +314,6 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
@@ -331,7 +322,9 @@ ENABLE_REALTIME_CHAT_SAVE = (
# REDIS
####################################
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
####################################
# WEBUI_AUTH (Required for security)
@@ -386,6 +379,11 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("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", "")
@@ -397,19 +395,18 @@ else:
except Exception:
AIOHTTP_CLIENT_TIMEOUT = 300
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get(
"AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", ""
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
)
if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "":
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None
if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "":
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None
else:
try:
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int(
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST
)
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
except Exception:
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10
####################################
# OFFLINE_MODE
@@ -419,3 +416,47 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"
####################################
# AUDIT LOGGING
####################################
# Where to store log file
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
# Maximum size of a file before rotating into a new log file
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
# METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
try:
MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048)
except ValueError:
MAX_BODY_LOG_SIZE = 2048
# Comma separated list for urls to exclude from audit
AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders").split(
","
)
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
# OPENTELEMETRY
####################################
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
) # e.g. key1=val1,key2=val2
OTEL_TRACES_SAMPLER = os.environ.get(
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
).lower()
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
+9 -3
View File
@@ -2,6 +2,7 @@ import logging
import sys
import inspect
import json
import asyncio
from pydantic import BaseModel
from typing import AsyncGenerator, Generator, Iterator
@@ -76,11 +77,13 @@ async def get_function_models(request):
if hasattr(function_module, "pipes"):
sub_pipes = []
# Check if pipes is a function or a list
# Handle pipes being a list, sync function, or async function
try:
if callable(function_module.pipes):
sub_pipes = function_module.pipes()
if asyncio.iscoroutinefunction(function_module.pipes):
sub_pipes = await function_module.pipes()
else:
sub_pipes = function_module.pipes()
else:
sub_pipes = function_module.pipes
except Exception as e:
@@ -220,6 +223,9 @@ async def generate_function_chat_completion(
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
"__chat_id__": metadata.get("chat_id", None),
"__session_id__": metadata.get("session_id", None),
"__message_id__": metadata.get("message_id", None),
"__task__": __task__,
"__task_body__": __task_body__,
"__files__": files,
+122 -20
View File
@@ -45,6 +45,9 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse
from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.logger import start_logger
from open_webui.socket.main import (
app as socket_app,
periodic_usage_pool_cleanup,
@@ -81,11 +84,12 @@ from open_webui.routers.retrieval import (
get_rf,
)
from open_webui.internal.db import Session
from open_webui.internal.db import Session, engine
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.config import (
LICENSE_KEY,
@@ -95,12 +99,14 @@ from open_webui.config import (
OLLAMA_API_CONFIGS,
# OpenAI
ENABLE_OPENAI_API,
ONEDRIVE_CLIENT_ID,
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
OPENAI_API_CONFIGS,
# Direct Connections
ENABLE_DIRECT_CONNECTIONS,
# Code Execution
ENABLE_CODE_EXECUTION,
CODE_EXECUTION_ENGINE,
CODE_EXECUTION_JUPYTER_URL,
CODE_EXECUTION_JUPYTER_AUTH,
@@ -150,6 +156,7 @@ from open_webui.config import (
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
RAG_WEB_LOADER_ENGINE,
@@ -161,6 +168,7 @@ from open_webui.config import (
RAG_TEMPLATE,
DEFAULT_RAG_TEMPLATE,
RAG_FULL_CONTEXT,
BYPASS_EMBEDDING_AND_RETRIEVAL,
RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
@@ -180,7 +188,11 @@ from open_webui.config import (
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
@@ -188,7 +200,7 @@ from open_webui.config import (
YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search)
RAG_WEB_SEARCH_ENGINE,
RAG_WEB_SEARCH_FULL_CONTEXT,
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
RAG_WEB_SEARCH_RESULT_COUNT,
RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
RAG_WEB_SEARCH_TRUST_ENV,
@@ -204,10 +216,12 @@ from open_webui.config import (
SERPSTACK_API_KEY,
SERPSTACK_HTTPS,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
BING_SEARCH_V7_ENDPOINT,
BING_SEARCH_V7_SUBSCRIPTION_KEY,
BRAVE_SEARCH_API_KEY,
EXA_API_KEY,
PERPLEXITY_API_KEY,
KAGI_SEARCH_API_KEY,
MOJEEK_SEARCH_API_KEY,
BOCHA_SEARCH_API_KEY,
@@ -215,11 +229,13 @@ from open_webui.config import (
GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
ONEDRIVE_CLIENT_ID,
ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
ENABLE_RAG_WEB_SEARCH,
ENABLE_GOOGLE_DRIVE_INTEGRATION,
ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR,
# WebUI
WEBUI_AUTH,
@@ -237,6 +253,7 @@ from open_webui.config import (
ENABLE_CHANNELS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
ENABLE_EVALUATION_ARENA_MODELS,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
@@ -298,8 +315,14 @@ from open_webui.config import (
reset_config,
)
from open_webui.env import (
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE,
SAFE_MODE,
SRC_LOG_LEVELS,
VERSION,
@@ -313,6 +336,7 @@ from open_webui.env import (
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
OFFLINE_MODE,
ENABLE_OTEL,
)
@@ -340,6 +364,8 @@ from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.tasks import stop_task, list_tasks # Import from tasks.py
from open_webui.utils.redis import get_sentinels_from_env
if SAFE_MODE:
print("SAFE MODE ENABLED")
@@ -384,11 +410,12 @@ https://github.com/open-webui/open-webui
@asynccontextmanager
async def lifespan(app: FastAPI):
start_logger()
if RESET_CONFIG_ON_START:
reset_config()
if app.state.config.LICENSE_KEY:
get_license_data(app, app.state.config.LICENSE_KEY)
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
asyncio.create_task(periodic_usage_pool_cleanup())
yield
@@ -403,10 +430,26 @@ app = FastAPI(
oauth_manager = OAuthManager(app)
app.state.config = AppConfig()
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
)
app.state.WEBUI_NAME = WEBUI_NAME
app.state.config.LICENSE_KEY = LICENSE_KEY
app.state.LICENSE_METADATA = None
########################################
#
# OPENTELEMETRY
#
########################################
if ENABLE_OTEL:
from open_webui.utils.telemetry.setup import setup as setup_opentelemetry
setup_opentelemetry(app=app, db_engine=engine)
########################################
#
@@ -477,6 +520,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
@@ -520,12 +564,14 @@ app.state.FUNCTIONS = {}
app.state.config.TOP_K = RAG_TOP_K
app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER
app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT
app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = BYPASS_EMBEDDING_AND_RETRIEVAL
app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH
app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
@@ -533,6 +579,9 @@ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
@@ -560,10 +609,13 @@ app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = RAG_WEB_SEARCH_FULL_CONTEXT
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
@@ -584,14 +636,17 @@ app.state.config.JINA_API_KEY = JINA_API_KEY
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
app.state.config.EXA_API_KEY = EXA_API_KEY
app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT
app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.RAG_WEB_LOADER_ENGINE = RAG_WEB_LOADER_ENGINE
app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV
app.state.config.PLAYWRIGHT_WS_URI = PLAYWRIGHT_WS_URI
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None
app.state.ef = None
@@ -639,6 +694,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
#
########################################
app.state.config.ENABLE_CODE_EXECUTION = ENABLE_CODE_EXECUTION
app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE
app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL
app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH
@@ -879,6 +935,19 @@ app.include_router(
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f"Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}")
audit_level = AuditLevel.NONE
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
max_body_size=MAX_BODY_LOG_SIZE,
)
##################################
#
# Chat Endpoints
@@ -911,14 +980,24 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models
models = await get_all_models(request)
all_models = await get_all_models(request, user=user)
# Filter out filter pipelines
models = [
model
for model in models
if "pipeline" not in model or model["pipeline"].get("type", None) != "filter"
]
models = []
for model in all_models:
# Filter out filter pipelines
if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
continue
model_tags = [
tag.get("name")
for tag in model.get("info", {}).get("meta", {}).get("tags", [])
]
tags = [tag.get("name") for tag in model.get("tags", [])]
tags = list(set(model_tags + tags))
model["tags"] = [{"name": tag} for tag in tags]
models.append(model)
model_order_list = request.app.state.config.MODEL_ORDER_LIST
if model_order_list:
@@ -940,7 +1019,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
@app.get("/api/models/base")
async def get_base_models(request: Request, user=Depends(get_admin_user)):
models = await get_all_base_models(request)
models = await get_all_base_models(request, user=user)
return {"data": models}
@@ -951,7 +1030,7 @@ async def chat_completion(
user=Depends(get_verified_user),
):
if not request.app.state.MODELS:
await get_all_models(request)
await get_all_models(request, user=user)
model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None)
@@ -984,10 +1063,11 @@ async def chat_completion(
"message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None),
"tool_ids": form_data.get("tool_ids", None),
"tool_servers": form_data.pop("tool_servers", None),
"files": form_data.get("files", None),
"features": form_data.get("features", None),
"variables": form_data.get("variables", None),
"model": model_info.model_dump() if model_info else model,
"model": model,
"direct": model_item.get("direct", False),
**(
{"function_calling": "native"}
@@ -1005,11 +1085,19 @@ async def chat_completion(
form_data["metadata"] = metadata
form_data, metadata, events = await process_chat_payload(
request, form_data, metadata, user, model
request, form_data, user, metadata, model
)
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
@@ -1019,7 +1107,7 @@ async def chat_completion(
response = await chat_completion_handler(request, form_data, user)
return await process_chat_response(
request, response, form_data, user, events, metadata, tasks
request, response, form_data, user, metadata, model, events, tasks
)
except Exception as e:
raise HTTPException(
@@ -1108,9 +1196,10 @@ async def get_app_config(request: Request):
if data is not None and "id" in data:
user = Users.get_user_by_id(data["id"])
user_count = Users.get_num_users()
onboarding = False
if user is None:
user_count = Users.get_num_users()
onboarding = user_count == 0
return {
@@ -1138,14 +1227,17 @@ async def get_app_config(request: Request):
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
"enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION,
"enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER,
"enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
"enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
}
if user is not None
else {}
@@ -1155,6 +1247,7 @@ async def get_app_config(request: Request):
{
"default_models": app.state.config.DEFAULT_MODELS,
"default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS,
"user_count": user_count,
"code": {
"engine": app.state.config.CODE_EXECUTION_ENGINE,
},
@@ -1177,6 +1270,15 @@ async def get_app_config(request: Request):
"client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value},
"license_metadata": app.state.LICENSE_METADATA,
**(
{
"active_entries": app.state.USER_COUNT,
}
if user.role == "admin"
else {}
),
}
if user is not None
else {}
+9 -5
View File
@@ -1,3 +1,4 @@
import logging
import json
import time
import uuid
@@ -5,7 +6,7 @@ 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.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
@@ -16,6 +17,9 @@ from sqlalchemy.sql import exists
# Chat DB Schema
####################
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Chat(Base):
__tablename__ = "chat"
@@ -670,7 +674,7 @@ class ChatTable:
# Perform pagination at the SQL level
all_chats = query.offset(skip).limit(limit).all()
print(len(all_chats))
log.info(f"The number of chats: {len(all_chats)}")
# Validate and return chats
return [ChatModel.model_validate(chat) for chat in all_chats]
@@ -731,7 +735,7 @@ class ChatTable:
query = db.query(Chat).filter_by(user_id=user_id)
tag_id = tag_name.replace(" ", "_").lower()
print(db.bind.dialect.name)
log.info(f"DB dialect name: {db.bind.dialect.name}")
if db.bind.dialect.name == "sqlite":
# SQLite JSON1 querying for tags within the meta JSON field
query = query.filter(
@@ -752,7 +756,7 @@ class ChatTable:
)
all_chats = query.all()
print("all_chats", all_chats)
log.debug(f"all_chats: {all_chats}")
return [ChatModel.model_validate(chat) for chat in all_chats]
def add_chat_tag_by_id_and_user_id_and_tag_name(
@@ -810,7 +814,7 @@ class ChatTable:
count = query.count()
# Debugging output for inspection
print(f"Count of chats for tag '{tag_name}':", count)
log.info(f"Count of chats for tag '{tag_name}': {count}")
return count
+1 -1
View File
@@ -118,7 +118,7 @@ class FeedbackTable:
else:
return None
except Exception as e:
print(e)
log.exception(f"Error creating a new feedback: {e}")
return None
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]:
+1 -1
View File
@@ -119,7 +119,7 @@ class FilesTable:
else:
return None
except Exception as e:
print(f"Error creating tool: {e}")
log.exception(f"Error inserting a new file: {e}")
return None
def get_file_by_id(self, id: str) -> Optional[FileModel]:
+14 -7
View File
@@ -9,6 +9,8 @@ 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__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -82,7 +84,7 @@ class FolderTable:
else:
return None
except Exception as e:
print(e)
log.exception(f"Error inserting a new folder: {e}")
return None
def get_folder_by_id_and_user_id(
@@ -234,15 +236,18 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool:
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return False
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
if delete_chats:
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
# Delete all children folders
def delete_children(folder):
@@ -250,9 +255,11 @@ class FolderTable:
folder.id, user_id
)
for folder_child in folder_children:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
if delete_chats:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
delete_children(folder_child)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
+8 -4
View File
@@ -105,7 +105,7 @@ class FunctionsTable:
else:
return None
except Exception as e:
print(f"Error creating tool: {e}")
log.exception(f"Error creating a new function: {e}")
return None
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
@@ -170,7 +170,7 @@ class FunctionsTable:
function = db.get(Function, id)
return function.valves if function.valves else {}
except Exception as e:
print(f"An error occurred: {e}")
log.exception(f"Error getting function valves by id {id}: {e}")
return None
def update_function_valves_by_id(
@@ -202,7 +202,9 @@ class FunctionsTable:
return user_settings["functions"]["valves"].get(id, {})
except Exception as e:
print(f"An error occurred: {e}")
log.exception(
f"Error getting user values by id {id} and user id {user_id}: {e}"
)
return None
def update_user_valves_by_id_and_user_id(
@@ -225,7 +227,9 @@ class FunctionsTable:
return user_settings["functions"]["valves"][id]
except Exception as e:
print(f"An error occurred: {e}")
log.exception(
f"Error updating user valves by id {id} and user_id {user_id}: {e}"
)
return None
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]:
+2 -3
View File
@@ -166,7 +166,7 @@ class ModelsTable:
else:
return None
except Exception as e:
print(e)
log.exception(f"Failed to insert a new model: {e}")
return None
def get_all_models(self) -> list[ModelModel]:
@@ -246,8 +246,7 @@ class ModelsTable:
db.refresh(model)
return ModelModel.model_validate(model)
except Exception as e:
print(e)
log.exception(f"Failed to update the model by id {id}: {e}")
return None
def delete_model_by_id(self, id: str) -> bool:
+1 -1
View File
@@ -61,7 +61,7 @@ class TagTable:
else:
return None
except Exception as e:
print(e)
log.exception(f"Error inserting a new tag: {e}")
return None
def get_tag_by_name_and_user_id(
+8 -4
View File
@@ -131,7 +131,7 @@ class ToolsTable:
else:
return None
except Exception as e:
print(f"Error creating tool: {e}")
log.exception(f"Error creating a new tool: {e}")
return None
def get_tool_by_id(self, id: str) -> Optional[ToolModel]:
@@ -175,7 +175,7 @@ class ToolsTable:
tool = db.get(Tool, id)
return tool.valves if tool.valves else {}
except Exception as e:
print(f"An error occurred: {e}")
log.exception(f"Error getting tool valves by id {id}: {e}")
return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
@@ -204,7 +204,9 @@ class ToolsTable:
return user_settings["tools"]["valves"].get(id, {})
except Exception as e:
print(f"An error occurred: {e}")
log.exception(
f"Error getting user values by id {id} and user_id {user_id}: {e}"
)
return None
def update_user_valves_by_id_and_user_id(
@@ -227,7 +229,9 @@ class ToolsTable:
return user_settings["tools"]["valves"][id]
except Exception as e:
print(f"An error occurred: {e}")
log.exception(
f"Error updating user valves by id {id} and user_id {user_id}: {e}"
)
return None
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]:
+77 -2
View File
@@ -4,6 +4,7 @@ import ftfy
import sys
from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
BSHTMLLoader,
CSVLoader,
Docx2txtLoader,
@@ -76,6 +77,7 @@ known_source_ext = [
"jsx",
"hs",
"lhs",
"json",
]
@@ -103,7 +105,7 @@ class TikaLoader:
if r.ok:
raw_metadata = r.json()
text = raw_metadata.get("X-TIKA:content", "<No text content found>")
text = raw_metadata.get("X-TIKA:content", "<No text content found>").strip()
if "Content-Type" in raw_metadata:
headers["Content-Type"] = raw_metadata["Content-Type"]
@@ -115,6 +117,52 @@ class TikaLoader:
raise Exception(f"Error calling Tika: {r.reason}")
class DoclingLoader:
def __init__(self, url, file_path=None, mime_type=None):
self.url = url.rstrip("/")
self.file_path = file_path
self.mime_type = mime_type
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
params = {
"image_export_mode": "placeholder",
"table_mode": "accurate",
}
endpoint = f"{self.url}/v1alpha/convert/file"
r = requests.post(endpoint, files=files, data=params)
if r.ok:
result = r.json()
document_data = result.get("document", {})
text = document_data.get("md_content", "<No text content found>")
metadata = {"Content-Type": self.mime_type} if self.mime_type else {}
log.debug("Docling extracted text: %s", text)
return [Document(page_content=text, metadata=metadata)]
else:
error_msg = f"Error calling Docling API: {r.reason}"
if r.text:
try:
error_data = r.json()
if "detail" in error_data:
error_msg += f" - {error_data['detail']}"
except Exception:
error_msg += f" - {r.text}"
raise Exception(f"Error calling Docling: {error_msg}")
class Loader:
def __init__(self, engine: str = "", **kwargs):
self.engine = engine
@@ -147,13 +195,40 @@ class Loader:
file_path=file_path,
mime_type=file_content_type,
)
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
loader = DoclingLoader(
url=self.kwargs.get("DOCLING_SERVER_URL"),
file_path=file_path,
mime_type=file_content_type,
)
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
in [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
]
)
):
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
else:
if file_ext == "pdf":
loader = PyPDFLoader(
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
)
elif file_ext == "csv":
loader = CSVLoader(file_path)
loader = CSVLoader(file_path, autodetect_encoding=True)
elif file_ext == "rst":
loader = UnstructuredRSTLoader(file_path, mode="elements")
elif file_ext == "xml":
@@ -0,0 +1,93 @@
import requests
import logging
from typing import Iterator, List, Literal, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class TavilyLoader(BaseLoader):
"""Extract web page content from URLs using Tavily Extract API.
This is a LangChain document loader that uses Tavily's Extract API to
retrieve content from web pages and return it as Document objects.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
extract_depth: Depth of extraction, either "basic" or "advanced".
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
def __init__(
self,
urls: Union[str, List[str]],
api_key: str,
extract_depth: Literal["basic", "advanced"] = "basic",
continue_on_failure: bool = True,
) -> None:
"""Initialize Tavily Extract client.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
include_images: Whether to include images in the extraction.
extract_depth: Depth of extraction, either "basic" or "advanced".
advanced extraction retrieves more data, including tables and
embedded content, with higher success but may increase latency.
basic costs 1 credit per 5 successful URL extractions,
advanced costs 2 credits per 5 successful URL extractions.
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
if not urls:
raise ValueError("At least one URL must be provided.")
self.api_key = api_key
self.urls = urls if isinstance(urls, list) else [urls]
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.api_url = "https://api.tavily.com/extract"
def lazy_load(self) -> Iterator[Document]:
"""Extract and yield documents from the URLs using Tavily Extract API."""
batch_size = 20
for i in range(0, len(self.urls), batch_size):
batch_urls = self.urls[i : i + batch_size]
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
# Use string for single URL, array for multiple URLs
urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls
payload = {"urls": urls_param, "extract_depth": self.extract_depth}
# Make the API call
response = requests.post(self.api_url, headers=headers, json=payload)
response.raise_for_status()
response_data = response.json()
# Process successful results
for result in response_data.get("results", []):
url = result.get("url", "")
content = result.get("raw_content", "")
if not content:
log.warning(f"No content extracted from {url}")
continue
# Add URLs as metadata
metadata = {"source": url}
yield Document(
page_content=content,
metadata=metadata,
)
for failed in response_data.get("failed_results", []):
url = failed.get("url", "")
error = failed.get("error", "Unknown error")
log.error(f"Failed to extract content from {url}: {error}")
except Exception as e:
if self.continue_on_failure:
log.error(f"Error extracting content from batch {batch_urls}: {e}")
else:
raise e
@@ -1,13 +1,19 @@
import os
import logging
import torch
import numpy as np
from colbert.infra import ColBERTConfig
from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT:
def __init__(self, name, **kwargs) -> None:
print("ColBERT: Loading model", name)
log.info("ColBERT: Loading model", name)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
DOCKER = kwargs.get("env") == "docker"
+240 -112
View File
@@ -1,27 +1,35 @@
import logging
import os
import uuid
from typing import Optional, Union
import asyncio
import requests
import hashlib
from concurrent.futures import ThreadPoolExecutor
from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.utils.misc import get_last_user_message
from open_webui.models.users import UserModel
from open_webui.models.files import Files
from open_webui.retrieval.vector.main import GetResult
from open_webui.env import (
SRC_LOG_LEVELS,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_PREFIX_FIELD_NAME,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -46,7 +54,7 @@ class VectorSearchRetriever(BaseRetriever):
) -> list[Document]:
result = VECTOR_DB_CLIENT.search(
collection_name=self.collection_name,
vectors=[self.embedding_function(query)],
vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)],
limit=self.top_k,
)
@@ -80,7 +88,7 @@ def query_doc(
return result
except Exception as e:
print(e)
log.exception(f"Error querying doc {collection_name} with limit {k}: {e}")
raise e
@@ -93,24 +101,24 @@ def get_doc(collection_name: str, user: UserModel = None):
return result
except Exception as e:
print(e)
log.exception(f"Error getting doc {collection_name}: {e}")
raise e
def query_doc_with_hybrid_search(
collection_name: str,
collection_result: GetResult,
query: str,
embedding_function,
k: int,
reranking_function,
k_reranker: int,
r: float,
) -> dict:
try:
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
bm25_retriever = BM25Retriever.from_texts(
texts=result.documents[0],
metadatas=result.metadatas[0],
texts=collection_result.documents[0],
metadatas=collection_result.metadatas[0],
)
bm25_retriever.k = k
@@ -125,7 +133,7 @@ def query_doc_with_hybrid_search(
)
compressor = RerankCompressor(
embedding_function=embedding_function,
top_n=k,
top_n=k_reranker,
reranking_function=reranking_function,
r_score=r,
)
@@ -135,10 +143,23 @@ def query_doc_with_hybrid_search(
)
result = compression_retriever.invoke(query)
distances = [d.metadata.get("score") for d in result]
documents = [d.page_content for d in result]
metadatas = [d.metadata for d in result]
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
if k < k_reranker:
sorted_items = sorted(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
distances, documents, metadatas = map(list, zip(*sorted_items))
result = {
"distances": [[d.metadata.get("score") for d in result]],
"documents": [[d.page_content for d in result]],
"metadatas": [[d.metadata for d in result]],
"distances": [distances],
"documents": [documents],
"metadatas": [metadatas],
}
log.info(
@@ -171,62 +192,45 @@ def merge_get_results(get_results: list[dict]) -> dict:
return result
def merge_and_sort_query_results(
query_results: list[dict], k: int, reverse: bool = False
) -> list[dict]:
def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
# Initialize lists to store combined data
combined_distances = []
combined_documents = []
combined_metadatas = []
combined_ids = []
combined = dict() # To store documents with unique document hashes
for data in query_results:
combined_distances.extend(data["distances"][0])
combined_documents.extend(data["documents"][0])
combined_metadatas.extend(data["metadatas"][0])
# DISTINCT(chunk_id,file_id) - in case if id (chunk_ids) become ordinals
combined_ids.extend(
[
f"{id}-{meta['file_id']}"
for id, meta in zip(data["ids"][0], data["metadatas"][0])
]
)
distances = data["distances"][0]
documents = data["documents"][0]
metadatas = data["metadatas"][0]
# Create a list of tuples (distance, document, metadata, ids)
combined = list(
zip(combined_distances, combined_documents, combined_metadatas, combined_ids)
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = hashlib.md5(
document.encode()
).hexdigest() # Compute a hash for uniqueness
if doc_hash not in combined.keys():
combined[doc_hash] = (distance, document, metadata)
continue # if doc is new, no further comparison is needed
# if doc is alredy in, but new distance is better, update
if distance > combined[doc_hash][0]:
combined[doc_hash] = (distance, document, metadata)
combined = list(combined.values())
# Sort the list based on distances
combined.sort(key=lambda x: x[0], reverse=True)
# Slice to keep only the top k elements
sorted_distances, sorted_documents, sorted_metadatas = (
zip(*combined[:k]) if combined else ([], [], [])
)
# Sort the list based on distances
combined.sort(key=lambda x: x[0], reverse=reverse)
sorted_distances = []
sorted_documents = []
sorted_metadatas = []
# Otherwise we don't have anything :-(
if combined:
# Unzip the sorted list
all_distances, all_documents, all_metadatas, all_ids = zip(*combined)
seen_ids = set()
# Slicing the lists to include only k elements
for index, id in enumerate(all_ids):
if id not in seen_ids:
sorted_distances.append(all_distances[index])
sorted_documents.append(all_documents[index])
sorted_metadatas.append(all_metadatas[index])
seen_ids.add(id)
if len(sorted_distances) >= k:
break
# Create the output dictionary
result = {
"distances": [sorted_distances],
"documents": [sorted_documents],
"metadatas": [sorted_metadatas],
# Create and return the output dictionary
return {
"distances": [list(sorted_distances)],
"documents": [list(sorted_documents)],
"metadatas": [list(sorted_metadatas)],
}
return result
def get_all_items_from_collections(collection_names: list[str]) -> dict:
results = []
@@ -253,7 +257,7 @@ def query_collection(
) -> dict:
results = []
for query in queries:
query_embedding = embedding_function(query)
query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
for collection_name in collection_names:
if collection_name:
try:
@@ -269,12 +273,7 @@ def query_collection(
else:
pass
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
return merge_and_sort_query_results(results, k=k)
def query_collection_with_hybrid_search(
@@ -283,39 +282,66 @@ def query_collection_with_hybrid_search(
embedding_function,
k: int,
reranking_function,
k_reranker: int,
r: float,
) -> dict:
results = []
error = False
# Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later
collection_results = {}
for collection_name in collection_names:
try:
for query in queries:
result = query_doc_with_hybrid_search(
collection_name=collection_name,
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
r=r,
)
results.append(result)
except Exception as e:
log.exception(
"Error when querying the collection with " f"hybrid_search: {e}"
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
collection_name=collection_name
)
error = True
except Exception as e:
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
if error:
log.info(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
)
def process_query(collection_name, query):
try:
result = query_doc_with_hybrid_search(
collection_name=collection_name,
collection_result=collection_results[collection_name],
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
)
return result, None
except Exception as e:
log.exception(f"Error when querying the collection with hybrid_search: {e}")
return None, e
tasks = [
(collection_name, query)
for collection_name in collection_names
for query in queries
]
with ThreadPoolExecutor() as executor:
future_results = [executor.submit(process_query, cn, q) for cn, q in tasks]
task_results = [future.result() for future in future_results]
for result, err in task_results:
if err is not None:
error = True
elif result is not None:
results.append(result)
if error and not results:
raise Exception(
"Hybrid search failed for all collections. Using Non hybrid search as fallback."
"Hybrid search failed for all collections. Using Non-hybrid search as fallback."
)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
return merge_and_sort_query_results(results, k=k)
def get_embedding_function(
@@ -327,39 +353,50 @@ def get_embedding_function(
embedding_batch_size,
):
if embedding_engine == "":
return lambda query, user=None: embedding_function.encode(query).tolist()
return lambda query, prefix=None, user=None: embedding_function.encode(
query, prompt=prefix if prefix else None
).tolist()
elif embedding_engine in ["ollama", "openai"]:
func = lambda query, user=None: generate_embeddings(
func = lambda query, prefix=None, user=None: generate_embeddings(
engine=embedding_engine,
model=embedding_model,
text=query,
prefix=prefix,
url=url,
key=key,
user=user,
)
def generate_multiple(query, user, func):
def generate_multiple(query, prefix, user, func):
if isinstance(query, list):
embeddings = []
for i in range(0, len(query), embedding_batch_size):
embeddings.extend(
func(query[i : i + embedding_batch_size], user=user)
func(
query[i : i + embedding_batch_size],
prefix=prefix,
user=user,
)
)
return embeddings
else:
return func(query, user)
return func(query, prefix, user)
return lambda query, user=None: generate_multiple(query, user, func)
return lambda query, prefix=None, user=None: generate_multiple(
query, prefix, user, func
)
else:
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
def get_sources_from_files(
request,
files,
queries,
embedding_function,
k,
reranking_function,
k_reranker,
r,
hybrid_search,
full_context=False,
@@ -372,19 +409,71 @@ def get_sources_from_files(
relevant_contexts = []
for file in files:
context = None
if file.get("docs"):
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
context = {
"documents": [[doc.get("content") for doc in file.get("docs")]],
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
}
elif file.get("context") == "full":
# Manual Full Mode Toggle
context = {
"documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
}
else:
context = None
elif (
file.get("type") != "web_search"
and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
):
# BYPASS_EMBEDDING_AND_RETRIEVAL
if file.get("type") == "collection":
file_ids = file.get("data", {}).get("file_ids", [])
documents = []
metadatas = []
for file_id in file_ids:
file_object = Files.get_file_by_id(file_id)
if file_object:
documents.append(file_object.data.get("content", ""))
metadatas.append(
{
"file_id": file_id,
"name": file_object.filename,
"source": file_object.filename,
}
)
context = {
"documents": [documents],
"metadatas": [metadatas],
}
elif file.get("id"):
file_object = Files.get_file_by_id(file.get("id"))
if file_object:
context = {
"documents": [[file_object.data.get("content", "")]],
"metadatas": [
[
{
"file_id": file.get("id"),
"name": file_object.filename,
"source": file_object.filename,
}
]
],
}
elif file.get("file").get("data"):
context = {
"documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [
[file.get("file").get("data", {}).get("metadata", {})]
],
}
else:
collection_names = []
if file.get("type") == "collection":
if file.get("legacy"):
@@ -424,6 +513,7 @@ def get_sources_from_files(
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
)
except Exception as e:
@@ -447,6 +537,7 @@ def get_sources_from_files(
if context:
if "data" in file:
del file["data"]
relevant_contexts.append({**context, "file": file})
sources = []
@@ -515,9 +606,14 @@ def generate_openai_batch_embeddings(
texts: list[str],
url: str = "https://api.openai.com/v1",
key: str = "",
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post(
f"{url}/embeddings",
headers={
@@ -534,7 +630,7 @@ def generate_openai_batch_embeddings(
else {}
),
},
json={"input": texts, "model": model},
json=json_data,
)
r.raise_for_status()
data = r.json()
@@ -543,14 +639,23 @@ def generate_openai_batch_embeddings(
else:
raise "Something went wrong :/"
except Exception as e:
print(e)
log.exception(f"Error generating openai batch embeddings: {e}")
return None
def generate_ollama_batch_embeddings(
model: str, texts: list[str], url: str, key: str = "", user: UserModel = None
model: str,
texts: list[str],
url: str,
key: str = "",
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post(
f"{url}/api/embed",
headers={
@@ -567,7 +672,7 @@ def generate_ollama_batch_embeddings(
else {}
),
},
json={"input": texts, "model": model},
json=json_data,
)
r.raise_for_status()
data = r.json()
@@ -577,19 +682,38 @@ def generate_ollama_batch_embeddings(
else:
raise "Something went wrong :/"
except Exception as e:
print(e)
log.exception(f"Error generating ollama batch embeddings: {e}")
return None
def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs):
def generate_embeddings(
engine: str,
model: str,
text: Union[str, list[str]],
prefix: Union[str, None] = None,
**kwargs,
):
url = kwargs.get("url", "")
key = kwargs.get("key", "")
user = kwargs.get("user")
if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None:
if isinstance(text, list):
text = [f"{prefix}{text_element}" for text_element in text]
else:
text = f"{prefix}{text}"
if engine == "ollama":
if isinstance(text, list):
embeddings = generate_ollama_batch_embeddings(
**{"model": model, "texts": text, "url": url, "key": key, "user": user}
**{
"model": model,
"texts": text,
"url": url,
"key": key,
"prefix": prefix,
"user": user,
}
)
else:
embeddings = generate_ollama_batch_embeddings(
@@ -598,16 +722,20 @@ def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **
"texts": [text],
"url": url,
"key": key,
"prefix": prefix,
"user": user,
}
)
return embeddings[0] if isinstance(text, str) else embeddings
elif engine == "openai":
if isinstance(text, list):
embeddings = generate_openai_batch_embeddings(model, text, url, key, user)
embeddings = generate_openai_batch_embeddings(
model, text, url, key, prefix, user
)
else:
embeddings = generate_openai_batch_embeddings(model, [text], url, key, user)
embeddings = generate_openai_batch_embeddings(
model, [text], url, key, prefix, user
)
return embeddings[0] if isinstance(text, str) else embeddings
@@ -643,9 +771,9 @@ class RerankCompressor(BaseDocumentCompressor):
else:
from sentence_transformers import util
query_embedding = self.embedding_function(query)
query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
document_embedding = self.embedding_function(
[doc.page_content for doc in documents]
[doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX
)
scores = util.cos_sim(query_embedding, document_embedding)[0]
@@ -16,6 +16,10 @@ elif VECTOR_DB == "pgvector":
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
VECTOR_DB_CLIENT = PgvectorClient()
elif VECTOR_DB == "elasticsearch":
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
VECTOR_DB_CLIENT = ElasticsearchClient()
else:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
+26 -9
View File
@@ -1,4 +1,5 @@
import chromadb
import logging
from chromadb import Settings
from chromadb.utils.batch_utils import create_batches
@@ -16,6 +17,10 @@ from open_webui.config import (
CHROMA_CLIENT_AUTH_PROVIDER,
CHROMA_CLIENT_AUTH_CREDENTIALS,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ChromaClient:
@@ -70,10 +75,16 @@ class ChromaClient:
n_results=limit,
)
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
# https://docs.trychroma.com/docs/collections/configure cosine equation
distances: list = result["distances"][0]
distances = [2 - dist for dist in distances]
distances = [[dist / 2 for dist in distances]]
return SearchResult(
**{
"ids": result["ids"],
"distances": result["distances"],
"distances": distances,
"documents": result["documents"],
"metadatas": result["metadatas"],
}
@@ -102,8 +113,7 @@ class ChromaClient:
}
)
return None
except Exception as e:
print(e)
except:
return None
def get(self, collection_name: str) -> Optional[GetResult]:
@@ -162,12 +172,19 @@ class ChromaClient:
filter: Optional[dict] = None,
):
# Delete the items from the collection based on the ids.
collection = self.client.get_collection(name=collection_name)
if collection:
if ids:
collection.delete(ids=ids)
elif filter:
collection.delete(where=filter)
try:
collection = self.client.get_collection(name=collection_name)
if collection:
if ids:
collection.delete(ids=ids)
elif filter:
collection.delete(where=filter)
except Exception as e:
# If collection doesn't exist, that's fine - nothing to delete
log.debug(
f"Attempted to delete from non-existent collection {collection_name}. Ignoring."
)
pass
def reset(self):
# Resets the database. This will delete all collections and item entries.
@@ -0,0 +1,295 @@
from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import (
ELASTICSEARCH_URL,
ELASTICSEARCH_CA_CERTS,
ELASTICSEARCH_API_KEY,
ELASTICSEARCH_USERNAME,
ELASTICSEARCH_PASSWORD,
ELASTICSEARCH_CLOUD_ID,
ELASTICSEARCH_INDEX_PREFIX,
SSL_ASSERT_FINGERPRINT,
)
class ElasticsearchClient:
"""
Important:
in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating
an index for each file but store it as a text field, while seperating to different index
baesd on the embedding length.
"""
def __init__(self):
self.index_prefix = ELASTICSEARCH_INDEX_PREFIX
self.client = Elasticsearch(
hosts=[ELASTICSEARCH_URL],
ca_certs=ELASTICSEARCH_CA_CERTS,
api_key=ELASTICSEARCH_API_KEY,
cloud_id=ELASTICSEARCH_CLOUD_ID,
basic_auth=(
(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD)
if ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD
else None
),
ssl_assert_fingerprint=SSL_ASSERT_FINGERPRINT,
)
# Status: works
def _get_index_name(self, dimension: int) -> str:
return f"{self.index_prefix}_d{str(dimension)}"
# Status: works
def _scan_result_to_get_result(self, result) -> GetResult:
if not result:
return None
ids = []
documents = []
metadatas = []
for hit in result:
ids.append(hit["_id"])
documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata"))
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
# Status: works
def _result_to_get_result(self, result) -> GetResult:
if not result["hits"]["hits"]:
return None
ids = []
documents = []
metadatas = []
for hit in result["hits"]["hits"]:
ids.append(hit["_id"])
documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata"))
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
# Status: works
def _result_to_search_result(self, result) -> SearchResult:
ids = []
distances = []
documents = []
metadatas = []
for hit in result["hits"]["hits"]:
ids.append(hit["_id"])
distances.append(hit["_score"])
documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata"))
return SearchResult(
ids=[ids],
distances=[distances],
documents=[documents],
metadatas=[metadatas],
)
# Status: works
def _create_index(self, dimension: int):
body = {
"mappings": {
"dynamic_templates": [
{
"strings": {
"match_mapping_type": "string",
"mapping": {"type": "keyword"},
}
}
],
"properties": {
"collection": {"type": "keyword"},
"id": {"type": "keyword"},
"vector": {
"type": "dense_vector",
"dims": dimension, # Adjust based on your vector dimensions
"index": True,
"similarity": "cosine",
},
"text": {"type": "text"},
"metadata": {"type": "object"},
},
}
}
self.client.indices.create(index=self._get_index_name(dimension), body=body)
# Status: works
def _create_batches(self, items: list[VectorItem], batch_size=100):
for i in range(0, len(items), batch_size):
yield items[i : min(i + batch_size, len(items))]
# Status: works
def has_collection(self, collection_name) -> bool:
query_body = {"query": {"bool": {"filter": []}}}
query_body["query"]["bool"]["filter"].append(
{"term": {"collection": collection_name}}
)
try:
result = self.client.count(index=f"{self.index_prefix}*", body=query_body)
return result.body["count"] > 0
except Exception as e:
return None
def delete_collection(self, collection_name: str):
query = {"query": {"term": {"collection": collection_name}}}
self.client.delete_by_query(index=f"{self.index_prefix}*", body=query)
# Status: works
def search(
self, collection_name: str, vectors: list[list[float]], limit: int
) -> Optional[SearchResult]:
query = {
"size": limit,
"_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {
"bool": {"filter": [{"term": {"collection": collection_name}}]}
},
"script": {
"source": "cosineSimilarity(params.vector, 'vector') + 1.0",
"params": {
"vector": vectors[0]
}, # Assuming single query vector
},
}
},
}
result = self.client.search(
index=self._get_index_name(len(vectors[0])), body=query
)
return self._result_to_search_result(result)
# Status: only tested halfwat
def query(
self, collection_name: str, filter: dict, limit: Optional[int] = None
) -> Optional[GetResult]:
if not self.has_collection(collection_name):
return None
query_body = {
"query": {"bool": {"filter": []}},
"_source": ["text", "metadata"],
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append({"term": {field: value}})
query_body["query"]["bool"]["filter"].append(
{"term": {"collection": collection_name}}
)
size = limit if limit else 10
try:
result = self.client.search(
index=f"{self.index_prefix}*",
body=query_body,
size=size,
)
return self._result_to_get_result(result)
except Exception as e:
return None
# Status: works
def _has_index(self, dimension: int):
return self.client.indices.exists(
index=self._get_index_name(dimension=dimension)
)
def get_or_create_index(self, dimension: int):
if not self._has_index(dimension=dimension):
self._create_index(dimension=dimension)
# Status: works
def get(self, collection_name: str) -> Optional[GetResult]:
# Get all the items in the collection.
query = {
"query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}},
"_source": ["text", "metadata"],
}
results = list(scan(self.client, index=f"{self.index_prefix}*", query=query))
return self._scan_result_to_get_result(results)
# Status: works
def insert(self, collection_name: str, items: list[VectorItem]):
if not self._has_index(dimension=len(items[0]["vector"])):
self._create_index(dimension=len(items[0]["vector"]))
for batch in self._create_batches(items):
actions = [
{
"_index": self._get_index_name(dimension=len(items[0]["vector"])),
"_id": item["id"],
"_source": {
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
for item in batch
]
bulk(self.client, actions)
# Upsert documents using the update API with doc_as_upsert=True.
def upsert(self, collection_name: str, items: list[VectorItem]):
if not self._has_index(dimension=len(items[0]["vector"])):
self._create_index(dimension=len(items[0]["vector"]))
for batch in self._create_batches(items):
actions = [
{
"_op_type": "update",
"_index": self._get_index_name(dimension=len(item["vector"])),
"_id": item["id"],
"doc": {
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
"doc_as_upsert": True,
}
for item in batch
]
bulk(self.client, actions)
# Delete specific documents from a collection by filtering on both collection and document IDs.
def delete(
self,
collection_name: str,
ids: Optional[list[str]] = None,
filter: Optional[dict] = None,
):
query = {
"query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}
}
# logic based on chromaDB
if ids:
query["query"]["bool"]["filter"].append({"terms": {"_id": ids}})
elif filter:
for field, value in filter.items():
query["query"]["bool"]["filter"].append(
{"term": {f"metadata.{field}": value}}
)
self.client.delete_by_query(index=f"{self.index_prefix}*", body=query)
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}*")
for index in indices:
self.client.indices.delete(index=index)
@@ -1,7 +1,7 @@
from pymilvus import MilvusClient as Client
from pymilvus import FieldSchema, DataType
import json
import logging
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
@@ -10,15 +10,19 @@ from open_webui.config import (
MILVUS_DB,
MILVUS_TOKEN,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MilvusClient:
def __init__(self):
self.collection_prefix = "open_webui"
if MILVUS_TOKEN is None:
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB)
else:
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN)
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN)
def _result_to_get_result(self, result) -> GetResult:
ids = []
@@ -60,7 +64,10 @@ class MilvusClient:
for item in match:
_ids.append(item.get("id"))
_distances.append(item.get("distance"))
# normalize milvus score from [-1, 1] to [0, 1] range
# https://milvus.io/docs/de/metric.md
_dist = (item.get("distance") + 1.0) / 2.0
_distances.append(_dist)
_documents.append(item.get("entity", {}).get("data", {}).get("text"))
_metadatas.append(item.get("entity", {}).get("metadata"))
@@ -168,7 +175,7 @@ class MilvusClient:
try:
# Loop until there are no more items to fetch or the desired limit is reached
while remaining > 0:
print("remaining", remaining)
log.info(f"remaining: {remaining}")
current_fetch = min(
max_limit, remaining
) # Determine how many items to fetch in this iteration
@@ -195,10 +202,12 @@ class MilvusClient:
if results_count < current_fetch:
break
print(all_results)
log.debug(all_results)
return self._result_to_get_result([all_results])
except Exception as e:
print(e)
log.exception(
f"Error querying collection {collection_name} with limit {limit}: {e}"
)
return None
def get(self, collection_name: str) -> Optional[GetResult]:
@@ -1,4 +1,5 @@
from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
@@ -21,7 +22,13 @@ class OpenSearchClient:
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
)
def _get_index_name(self, collection_name: str) -> str:
return f"{self.index_prefix}_{collection_name}"
def _result_to_get_result(self, result) -> GetResult:
if not result["hits"]["hits"]:
return None
ids = []
documents = []
metadatas = []
@@ -31,9 +38,12 @@ class OpenSearchClient:
documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata"))
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def _result_to_search_result(self, result) -> SearchResult:
if not result["hits"]["hits"]:
return None
ids = []
distances = []
documents = []
@@ -46,72 +56,88 @@ class OpenSearchClient:
metadatas.append(hit["_source"].get("metadata"))
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
ids=[ids],
distances=[distances],
documents=[documents],
metadatas=[metadatas],
)
def _create_index(self, index_name: str, dimension: int):
def _create_index(self, collection_name: str, dimension: int):
body = {
"settings": {"index": {"knn": True}},
"mappings": {
"properties": {
"id": {"type": "keyword"},
"vector": {
"type": "dense_vector",
"dims": dimension, # Adjust based on your vector dimensions
"index": true,
"type": "knn_vector",
"dimension": dimension, # Adjust based on your vector dimensions
"index": True,
"similarity": "faiss",
"method": {
"name": "hnsw",
"space_type": "ip", # Use inner product to approximate cosine similarity
"space_type": "innerproduct", # Use inner product to approximate cosine similarity
"engine": "faiss",
"ef_construction": 128,
"m": 16,
"parameters": {
"ef_construction": 128,
"m": 16,
},
},
},
"text": {"type": "text"},
"metadata": {"type": "object"},
}
}
},
}
self.client.indices.create(index=f"{self.index_prefix}_{index_name}", body=body)
self.client.indices.create(
index=self._get_index_name(collection_name), body=body
)
def _create_batches(self, items: list[VectorItem], batch_size=100):
for i in range(0, len(items), batch_size):
yield items[i : i + batch_size]
def has_collection(self, index_name: str) -> bool:
def has_collection(self, collection_name: str) -> bool:
# has_collection here means has index.
# We are simply adapting to the norms of the other DBs.
return self.client.indices.exists(index=f"{self.index_prefix}_{index_name}")
return self.client.indices.exists(index=self._get_index_name(collection_name))
def delete_colleciton(self, index_name: str):
def delete_collection(self, collection_name: str):
# delete_collection here means delete index.
# We are simply adapting to the norms of the other DBs.
self.client.indices.delete(index=f"{self.index_prefix}_{index_name}")
self.client.indices.delete(index=self._get_index_name(collection_name))
def search(
self, index_name: str, vectors: list[list[float]], limit: int
self, collection_name: str, vectors: list[list[float | int]], limit: int
) -> Optional[SearchResult]:
query = {
"size": limit,
"_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.vector, 'vector') + 1.0",
"params": {
"vector": vectors[0]
}, # Assuming single query vector
},
}
},
}
try:
if not self.has_collection(collection_name):
return None
result = self.client.search(
index=f"{self.index_prefix}_{index_name}", body=query
)
query = {
"size": limit,
"_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0",
"params": {
"field": "vector",
"query_value": vectors[0],
}, # Assuming single query vector
},
}
},
}
return self._result_to_search_result(result)
result = self.client.search(
index=self._get_index_name(collection_name), body=query
)
return self._result_to_search_result(result)
except Exception as e:
return None
def query(
self, collection_name: str, filter: dict, limit: Optional[int] = None
@@ -125,13 +151,15 @@ class OpenSearchClient:
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append({"term": {field: value}})
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
size = limit if limit else 10
try:
result = self.client.search(
index=f"{self.index_prefix}_{collection_name}",
index=self._get_index_name(collection_name),
body=query_body,
size=size,
)
@@ -141,64 +169,88 @@ class OpenSearchClient:
except Exception as e:
return None
def get_or_create_index(self, index_name: str, dimension: int):
if not self.has_index(index_name):
self._create_index(index_name, dimension)
def _create_index_if_not_exists(self, collection_name: str, dimension: int):
if not self.has_collection(collection_name):
self._create_index(collection_name, dimension)
def get(self, index_name: str) -> Optional[GetResult]:
def get(self, collection_name: str) -> Optional[GetResult]:
query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]}
result = self.client.search(
index=f"{self.index_prefix}_{index_name}", body=query
index=self._get_index_name(collection_name), body=query
)
return self._result_to_get_result(result)
def insert(self, index_name: str, items: list[VectorItem]):
if not self.has_index(index_name):
self._create_index(index_name, dimension=len(items[0]["vector"]))
def insert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
collection_name=collection_name, dimension=len(items[0]["vector"])
)
for batch in self._create_batches(items):
actions = [
{
"index": {
"_id": item["id"],
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
"_op_type": "index",
"_index": self._get_index_name(collection_name),
"_id": item["id"],
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
for item in batch
]
self.client.bulk(actions)
bulk(self.client, actions)
def upsert(self, index_name: str, items: list[VectorItem]):
if not self.has_index(index_name):
self._create_index(index_name, dimension=len(items[0]["vector"]))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
collection_name=collection_name, dimension=len(items[0]["vector"])
)
for batch in self._create_batches(items):
actions = [
{
"index": {
"_id": item["id"],
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
}
"_op_type": "update",
"_index": self._get_index_name(collection_name),
"_id": item["id"],
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
},
"doc_as_upsert": True,
}
for item in batch
]
self.client.bulk(actions)
bulk(self.client, actions)
def delete(self, index_name: str, ids: list[str]):
actions = [
{"delete": {"_index": f"{self.index_prefix}_{index_name}", "_id": id}}
for id in ids
]
self.client.bulk(body=actions)
def delete(
self,
collection_name: str,
ids: Optional[list[str]] = None,
filter: Optional[dict] = None,
):
if ids:
actions = [
{
"_op_type": "delete",
"_index": self._get_index_name(collection_name),
"_id": id,
}
for id in ids
]
bulk(self.client, actions)
elif filter:
query_body = {
"query": {"bool": {"filter": []}},
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
@@ -1,4 +1,5 @@
from typing import Optional, List, Dict, Any
import logging
from sqlalchemy import (
cast,
column,
@@ -24,9 +25,14 @@ from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class DocumentChunk(Base):
__tablename__ = "document_chunk"
@@ -82,10 +88,10 @@ class PgvectorClient:
)
)
self.session.commit()
print("Initialization complete.")
log.info("Initialization complete.")
except Exception as e:
self.session.rollback()
print(f"Error during initialization: {e}")
log.exception(f"Error during initialization: {e}")
raise
def check_vector_length(self) -> None:
@@ -150,12 +156,12 @@ class PgvectorClient:
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
print(
log.info(
f"Inserted {len(new_items)} items into collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
print(f"Error during insert: {e}")
log.exception(f"Error during insert: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
@@ -184,10 +190,12 @@ class PgvectorClient:
)
self.session.add(new_chunk)
self.session.commit()
print(f"Upserted {len(items)} items into collection '{collection_name}'.")
log.info(
f"Upserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
print(f"Error during upsert: {e}")
log.exception(f"Error during upsert: {e}")
raise
def search(
@@ -270,7 +278,9 @@ class PgvectorClient:
for row in results:
qid = int(row.qid)
ids[qid].append(row.id)
distances[qid].append(row.distance)
# normalize and re-orders pgvec distance from [2, 0] to [0, 1] score range
# https://github.com/pgvector/pgvector?tab=readme-ov-file#querying
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
@@ -278,7 +288,7 @@ class PgvectorClient:
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
print(f"Error during search: {e}")
log.exception(f"Error during search: {e}")
return None
def query(
@@ -310,7 +320,7 @@ class PgvectorClient:
metadatas=metadatas,
)
except Exception as e:
print(f"Error during query: {e}")
log.exception(f"Error during query: {e}")
return None
def get(
@@ -334,7 +344,7 @@ class PgvectorClient:
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
print(f"Error during get: {e}")
log.exception(f"Error during get: {e}")
return None
def delete(
@@ -356,22 +366,22 @@ class PgvectorClient:
)
deleted = query.delete(synchronize_session=False)
self.session.commit()
print(f"Deleted {deleted} items from collection '{collection_name}'.")
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
print(f"Error during delete: {e}")
log.exception(f"Error during delete: {e}")
raise
def reset(self) -> None:
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
print(
log.info(
f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
)
except Exception as e:
self.session.rollback()
print(f"Error during reset: {e}")
log.exception(f"Error during reset: {e}")
raise
def close(self) -> None:
@@ -387,9 +397,9 @@ class PgvectorClient:
)
return exists
except Exception as e:
print(f"Error checking collection existence: {e}")
log.exception(f"Error checking collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
print(f"Collection '{collection_name}' deleted.")
log.info(f"Collection '{collection_name}' deleted.")
@@ -1,4 +1,5 @@
from typing import Optional
import logging
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.models import PointStruct
@@ -6,9 +7,13 @@ from qdrant_client.models import models
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import QDRANT_URI, QDRANT_API_KEY
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 999999999
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient:
def __init__(self):
@@ -49,7 +54,7 @@ class QdrantClient:
),
)
print(f"collection {collection_name_with_prefix} successfully created!")
log.info(f"collection {collection_name_with_prefix} successfully created!")
def _create_collection_if_not_exists(self, collection_name, dimension):
if not self.has_collection(collection_name=collection_name):
@@ -94,7 +99,8 @@ class QdrantClient:
ids=get_result.ids,
documents=get_result.documents,
metadatas=get_result.metadatas,
distances=[[point.score for point in query_response.points]],
# qdrant distance is [-1, 1], normalize to [0, 1]
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
@@ -120,7 +126,7 @@ class QdrantClient:
)
return self._result_to_get_result(points.points)
except Exception as e:
print(e)
log.exception(f"Error querying a collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
@@ -0,0 +1,87 @@
import logging
from typing import Optional, List
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_perplexity(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Perplexity API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Perplexity API key
query (str): The query to search for
count (int): Maximum number of results to return
"""
# Handle PersistentConfig object
if hasattr(api_key, "__str__"):
api_key = str(api_key)
try:
url = "https://api.perplexity.ai/chat/completions"
# Create payload for the API call
payload = {
"model": "sonar",
"messages": [
{
"role": "system",
"content": "You are a search assistant. Provide factual information with citations.",
},
{"role": "user", "content": query},
],
"temperature": 0.2, # Lower temperature for more factual responses
"stream": False,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Make the API request
response = requests.request("POST", url, json=payload, headers=headers)
# Parse the JSON response
json_response = response.json()
# Extract citations from the response
citations = json_response.get("citations", [])
# Create search results from citations
results = []
for i, citation in enumerate(citations[:count]):
# Extract content from the response to use as snippet
content = ""
if "choices" in json_response and json_response["choices"]:
if i == 0:
content = json_response["choices"][0]["message"]["content"]
result = {"link": citation, "title": f"Source {i+1}", "snippet": content}
results.append(result)
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"]
)
for result in results[:count]
]
except Exception as e:
log.error(f"Error searching with Perplexity API: {e}")
return []
+172 -76
View File
@@ -24,13 +24,17 @@ from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoa
from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
RAG_WEB_LOADER_ENGINE,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -113,7 +117,47 @@ def verify_ssl_cert(url: str) -> bool:
return False
class SafeFireCrawlLoader(BaseLoader):
class RateLimitMixin:
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
class URLProcessingMixin:
def _verify_ssl_cert(self, url: str) -> bool:
"""Verify SSL certificate for a URL."""
return verify_ssl_cert(url)
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__(
self,
web_paths,
@@ -184,7 +228,7 @@ class SafeFireCrawlLoader(BaseLoader):
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
@@ -204,47 +248,124 @@ class SafeFireCrawlLoader(BaseLoader):
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__(
self,
web_paths: Union[str, List[str]],
api_key: str,
extract_depth: Literal["basic", "advanced"] = "basic",
continue_on_failure: bool = True,
requests_per_second: Optional[float] = None,
verify_ssl: bool = True,
trust_env: bool = False,
proxy: Optional[Dict[str, str]] = None,
):
"""Initialize SafeTavilyLoader with rate limiting and SSL verification support.
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
Args:
web_paths: List of URLs/paths to process.
api_key: The Tavily API key.
extract_depth: Depth of extraction ("basic" or "advanced").
continue_on_failure: Whether to continue if extraction of a URL fails.
requests_per_second: Number of requests per second to limit to.
verify_ssl: If True, verify SSL certificates.
trust_env: If True, use proxy settings from environment variables.
proxy: Optional proxy configuration.
"""
# Initialize proxy configuration if using environment variables
proxy_server = proxy.get("server") if proxy else None
if trust_env and not proxy_server:
env_proxies = urllib.request.getproxies()
env_proxy_server = env_proxies.get("https") or env_proxies.get("http")
if env_proxy_server:
if proxy:
proxy["server"] = env_proxy_server
else:
proxy = {"server": env_proxy_server}
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
# Store parameters for creating TavilyLoader instances
self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
self.api_key = api_key
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.verify_ssl = verify_ssl
self.trust_env = trust_env
self.proxy = proxy
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
# Add rate limiting
self.requests_per_second = requests_per_second
self.last_request_time = None
def lazy_load(self) -> Iterator[Document]:
"""Load documents with rate limiting support, delegating to TavilyLoader."""
valid_urls = []
for url in self.web_paths:
try:
self._safe_process_url_sync(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error extracting content from URLs: {e}")
else:
raise e
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async version with rate limiting and SSL verification."""
valid_urls = []
for url in self.web_paths:
try:
await self._safe_process_url(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
async for document in loader.alazy_load():
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error loading URLs: {e}")
else:
raise e
class SafePlaywrightURLLoader(PlaywrightURLLoader):
class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin):
"""Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection.
Attributes:
@@ -256,6 +377,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
headless (bool): If True, the browser will run in headless mode.
proxy (dict): Proxy override settings for the Playwright session.
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
playwright_timeout (Optional[int]): Maximum operation time in milliseconds.
"""
def __init__(
@@ -269,6 +391,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
remove_selectors: Optional[List[str]] = None,
proxy: Optional[Dict[str, str]] = None,
playwright_ws_url: Optional[str] = None,
playwright_timeout: Optional[int] = 10000,
):
"""Initialize with additional safety parameters and remote browser support."""
@@ -295,6 +418,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
self.last_request_time = None
self.playwright_ws_url = playwright_ws_url
self.trust_env = trust_env
self.playwright_timeout = playwright_timeout
def lazy_load(self) -> Iterator[Document]:
"""Safely load URLs synchronously with support for remote browser."""
@@ -311,7 +435,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try:
self._safe_process_url_sync(url)
page = browser.new_page()
response = page.goto(url)
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
@@ -320,7 +444,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
browser.close()
@@ -342,7 +466,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try:
await self._safe_process_url(url)
page = await browser.new_page()
response = await page.goto(url)
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
@@ -351,46 +475,11 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
log.exception(f"Error loading {url}: {e}")
continue
raise e
await browser.close()
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafeWebBaseLoader(WebBaseLoader):
"""WebBaseLoader with enhanced error handling for URLs."""
@@ -472,7 +561,7 @@ class SafeWebBaseLoader(WebBaseLoader):
yield Document(page_content=text, metadata=metadata)
except Exception as e:
# Log the error and continue with the next URL
log.exception(e, "Error loading %s", path)
log.exception(f"Error loading {path}: {e}")
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path."""
@@ -499,6 +588,7 @@ RAG_WEB_LOADER_ENGINES = defaultdict(lambda: SafeWebBaseLoader)
RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader
RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader
RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader
RAG_WEB_LOADER_ENGINES["tavily"] = SafeTavilyLoader
def get_web_loader(
@@ -518,13 +608,19 @@ def get_web_loader(
"trust_env": trust_env,
}
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "playwright":
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "firecrawl":
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if RAG_WEB_LOADER_ENGINE.value == "tavily":
web_loader_args["api_key"] = TAVILY_API_KEY.value
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
# Create the appropriate WebLoader based on the configuration
WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value]
web_loader = WebLoaderClass(**web_loader_args)
+54 -15
View File
@@ -54,7 +54,7 @@ MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["AUDIO"])
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech"
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -71,7 +71,7 @@ from pydub.utils import mediainfo
def is_mp4_audio(file_path):
"""Check if the given file is an MP4 audio file."""
if not os.path.isfile(file_path):
print(f"File not found: {file_path}")
log.error(f"File not found: {file_path}")
return False
info = mediainfo(file_path)
@@ -88,7 +88,7 @@ def convert_mp4_to_wav(file_path, output_path):
"""Convert MP4 audio file to WAV format."""
audio = AudioSegment.from_file(file_path, format="mp4")
audio.export(output_path, format="wav")
print(f"Converted {file_path} to {output_path}")
log.info(f"Converted {file_path} to {output_path}")
def set_faster_whisper_model(model: str, auto_update: bool = False):
@@ -266,7 +266,6 @@ async def speech(request: Request, user=Depends(get_verified_user)):
payload["model"] = request.app.state.config.TTS_MODEL
try:
# print(payload)
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
@@ -468,7 +467,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
def transcribe(request: Request, file_path):
print("transcribe", file_path)
log.info(f"transcribe: {file_path}")
filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path)
id = filename.split(".")[0]
@@ -626,7 +625,9 @@ def transcription(
):
log.info(f"file.content_type: {file.content_type}")
if file.content_type not in ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a"]:
supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a")
if not file.content_type.startswith(supported_filetypes):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
@@ -680,7 +681,22 @@ def transcription(
def get_available_models(request: Request) -> list[dict]:
available_models = []
if request.app.state.config.TTS_ENGINE == "openai":
available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}]
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith(
"https://api.openai.com"
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
)
response.raise_for_status()
data = response.json()
available_models = data.get("models", [])
except Exception as e:
log.error(f"Error fetching models from custom endpoint: {str(e)}")
available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}]
else:
available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}]
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
try:
response = requests.get(
@@ -711,14 +727,37 @@ def get_available_voices(request) -> dict:
"""Returns {voice_id: voice_name} dict"""
available_voices = {}
if request.app.state.config.TTS_ENGINE == "openai":
available_voices = {
"alloy": "alloy",
"echo": "echo",
"fable": "fable",
"onyx": "onyx",
"nova": "nova",
"shimmer": "shimmer",
}
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith(
"https://api.openai.com"
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
)
response.raise_for_status()
data = response.json()
voices_list = data.get("voices", [])
available_voices = {voice["id"]: voice["name"] for voice in voices_list}
except Exception as e:
log.error(f"Error fetching voices from custom endpoint: {str(e)}")
available_voices = {
"alloy": "alloy",
"echo": "echo",
"fable": "fable",
"onyx": "onyx",
"nova": "nova",
"shimmer": "shimmer",
}
else:
available_voices = {
"alloy": "alloy",
"echo": "echo",
"fable": "fable",
"onyx": "onyx",
"nova": "nova",
"shimmer": "shimmer",
}
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
try:
available_voices = get_elevenlabs_voices(
+26 -29
View File
@@ -31,10 +31,7 @@ from open_webui.env import (
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from open_webui.config import (
OPENID_PROVIDER_URL,
ENABLE_OAUTH_SIGNUP,
)
from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.auth import (
@@ -51,8 +48,10 @@ from open_webui.utils.access_control import get_permissions
from typing import Optional, List
from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars
if ENABLE_LDAP.value:
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars
router = APIRouter()
@@ -211,7 +210,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_APP_DN,
LDAP_APP_PASSWORD,
auto_bind="NONE",
authentication="SIMPLE",
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
)
if not connection_app.bind():
raise HTTPException(400, detail="Application account bind failed")
@@ -231,9 +230,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not mail or mail == "" or mail == "[]":
raise HTTPException(400, f"User {form_data.user} does not have mail.")
email = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not email or email == "" or email == "[]":
raise HTTPException(400, f"User {form_data.user} does not have email.")
else:
email = email.lower()
cn = str(entry["cn"])
user_dn = entry.entry_dn
@@ -248,18 +250,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
if not connection_user.bind():
raise HTTPException(400, f"Authentication failed for {form_data.user}")
user = Users.get_user_by_email(mail)
user = Users.get_user_by_email(email)
if not user:
try:
user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
role = (
"admin"
@@ -268,7 +262,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
)
user = Auths.insert_new_auth(
email=mail, password=str(uuid.uuid4()), name=cn, role=role
email=email,
password=str(uuid.uuid4()),
name=cn,
role=role,
)
if not user:
@@ -281,7 +278,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
except Exception as err:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
user = Auths.authenticate_user_by_trusted_header(mail)
user = Auths.authenticate_user_by_trusted_header(email)
if user:
token = create_token(
@@ -439,11 +436,6 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
)
user_count = Users.get_num_users()
if request.app.state.USER_COUNT and user_count >= request.app.state.USER_COUNT:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
@@ -613,7 +605,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None
print(admin_email, admin_name)
log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}")
if admin_email:
admin = Users.get_user_by_email(admin_email)
@@ -647,11 +639,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
@@ -662,11 +655,12 @@ class AdminConfig(BaseModel):
ENABLE_API_KEY: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str
ENABLE_CHANNELS: bool
DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
ENABLE_CHANNELS: bool
ENABLE_USER_WEBHOOKS: bool
@router.post("/admin/config")
@@ -701,6 +695,8 @@ async def update_admin_config(
)
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
@@ -713,6 +709,7 @@ async def update_admin_config(
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
+103
View File
@@ -2,6 +2,8 @@ import json
import logging
from typing import Optional
from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
ChatImportForm,
@@ -372,6 +374,107 @@ async def update_chat_by_id(
)
############################
# UpdateChatMessageById
############################
class MessageForm(BaseModel):
content: str
@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse])
async def update_chat_message_by_id(
id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.upsert_message_to_chat_by_id_and_message_id(
id,
message_id,
{
"content": form_data.content,
},
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
},
False,
)
if event_emitter:
await event_emitter(
{
"type": "chat:message",
"data": {
"chat_id": id,
"message_id": message_id,
"content": form_data.content,
},
}
)
return ChatResponse(**chat.model_dump())
############################
# SendChatMessageEventById
############################
class EventForm(BaseModel):
type: str
data: dict
@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool])
async def send_chat_message_event_by_id(
id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
}
)
try:
if event_emitter:
await event_emitter(form_data.model_dump())
else:
return False
return True
except:
return False
############################
# DeleteChatById
############################
+5
View File
@@ -70,6 +70,7 @@ async def set_direct_connections_config(
# CodeInterpreterConfig
############################
class CodeInterpreterConfigForm(BaseModel):
ENABLE_CODE_EXECUTION: bool
CODE_EXECUTION_ENGINE: str
CODE_EXECUTION_JUPYTER_URL: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH: Optional[str]
@@ -89,6 +90,7 @@ class CodeInterpreterConfigForm(BaseModel):
@router.get("/code_execution", response_model=CodeInterpreterConfigForm)
async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
return {
"ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION,
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
@@ -111,6 +113,8 @@ async def set_code_execution_config(
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
):
request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = (
form_data.CODE_EXECUTION_JUPYTER_URL
@@ -153,6 +157,7 @@ async def set_code_execution_config(
)
return {
"ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION,
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
+16 -2
View File
@@ -56,8 +56,19 @@ async def update_config(
}
class FeedbackUserReponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserModel] = None
user: Optional[FeedbackUserReponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@@ -65,7 +76,10 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return [
FeedbackUserResponse(
**feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id)
**feedback.model_dump(),
user=FeedbackUserReponse(
**Users.get_user_by_id(feedback.user_id).model_dump()
),
)
for feedback in feedbacks
]
+172 -31
View File
@@ -5,7 +5,16 @@ from pathlib import Path
from typing import Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Request,
UploadFile,
status,
Query,
)
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
@@ -15,7 +24,11 @@ from open_webui.models.files import (
FileModelResponse,
Files,
)
from open_webui.models.knowledge import Knowledges
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe
from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
@@ -26,6 +39,39 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# Check if the current user has access to a file through any knowledge bases the user may be in.
############################
def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user)
) -> bool:
file = Files.get_file_by_id(file_id)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
has_access = False
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
has_access = True
break
return has_access
############################
# Upload File
############################
@@ -37,6 +83,7 @@ def upload_file(
file: UploadFile = File(...),
user=Depends(get_verified_user),
file_metadata: dict = {},
process: bool = Query(True),
):
log.info(f"file.content_type: {file.content_type}")
try:
@@ -65,19 +112,33 @@ def upload_file(
}
),
)
try:
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 process:
try:
if file.content_type in [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
]:
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path)
process_file(
request,
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]:
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 file_item:
return file_item
@@ -144,7 +205,17 @@ async def delete_all_files(user=Depends(get_admin_user)):
async def get_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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)
):
return file
else:
raise HTTPException(
@@ -162,7 +233,17 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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)
):
return {"content": file.data.get("content", "")}
else:
raise HTTPException(
@@ -186,7 +267,17 @@ async def update_file_data_content_by_id(
):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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, "write", user)
):
try:
process_file(
request,
@@ -212,9 +303,22 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False)
):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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)
):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
@@ -230,17 +334,22 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
encoded_filename = quote(filename)
headers = {}
if content_type == "application/pdf" or filename.lower().endswith(
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
if attachment:
headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
else:
if content_type == "application/pdf" or filename.lower().endswith(
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
return FileResponse(file_path, headers=headers, media_type=content_type)
@@ -266,14 +375,25 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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)
):
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
# Check if the file already exists in the cache
if file_path.is_file():
print(f"file_path: {file_path}")
log.info(f"file_path: {file_path}")
return FileResponse(file_path)
else:
raise HTTPException(
@@ -298,7 +418,17 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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)
):
file_path = file.path
# Handle Unicode filenames
@@ -349,7 +479,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
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, "write", user)
):
# We should add Chroma cleanup here
result = Files.delete_file_by_id(id)
+15 -2
View File
@@ -20,11 +20,13 @@ from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -228,7 +230,18 @@ async def update_folder_is_expanded_by_id(
@router.delete("/{id}")
async def delete_folder_by_id(id: str, user=Depends(get_verified_user)):
async def delete_folder_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
if not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
+11 -5
View File
@@ -1,4 +1,5 @@
import os
import logging
from pathlib import Path
from typing import Optional
@@ -13,6 +14,11 @@ 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.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
@@ -68,7 +74,7 @@ async def create_new_function(
function = Functions.insert_new_function(user.id, function_type, form_data)
function_cache_dir = Path(CACHE_DIR) / "functions" / form_data.id
function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True)
if function:
@@ -79,7 +85,7 @@ async def create_new_function(
detail=ERROR_MESSAGES.DEFAULT("Error creating function"),
)
except Exception as e:
print(e)
log.exception(f"Failed to create a new function: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
@@ -183,7 +189,7 @@ async def update_function_by_id(
FUNCTIONS[id] = function_module
updated = {**form_data.model_dump(exclude={"id"}), "type": function_type}
print(updated)
log.debug(updated)
function = Functions.update_function_by_id(id, updated)
@@ -299,7 +305,7 @@ async def update_function_valves_by_id(
Functions.update_function_valves_by_id(id, valves.model_dump())
return valves.model_dump()
except Exception as e:
print(e)
log.exception(f"Error updating function values by id {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
@@ -388,7 +394,7 @@ async def update_function_user_valves_by_id(
)
return user_valves.model_dump()
except Exception as e:
print(e)
log.exception(f"Error updating function user valves by id {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
+11 -5
View File
@@ -1,7 +1,7 @@
import os
from pathlib import Path
from typing import Optional
import logging
from open_webui.models.users import Users
from open_webui.models.groups import (
@@ -14,7 +14,13 @@ from open_webui.models.groups import (
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.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
@@ -37,7 +43,7 @@ async def get_groups(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[GroupResponse])
async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)):
async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
try:
group = Groups.insert_new_group(user.id, form_data)
if group:
@@ -48,7 +54,7 @@ async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)
detail=ERROR_MESSAGES.DEFAULT("Error creating group"),
)
except Exception as e:
print(e)
log.exception(f"Error creating a new group: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
@@ -94,7 +100,7 @@ async def update_group_by_id(
detail=ERROR_MESSAGES.DEFAULT("Error updating group"),
)
except Exception as e:
print(e)
log.exception(f"Error updating group {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
@@ -118,7 +124,7 @@ async def delete_group_by_id(id: str, user=Depends(get_admin_user)):
detail=ERROR_MESSAGES.DEFAULT("Error deleting group"),
)
except Exception as e:
print(e)
log.exception(f"Error deleting group {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
+18 -4
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["IMAGES"])
IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/")
IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations"
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -144,6 +144,8 @@ async def update_config(
request.app.state.config.COMFYUI_BASE_URL = (
form_data.comfyui.COMFYUI_BASE_URL.strip("/")
)
request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY
request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW
request.app.state.config.COMFYUI_WORKFLOW_NODES = (
form_data.comfyui.COMFYUI_WORKFLOW_NODES
@@ -203,9 +205,17 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
try:
r = requests.get(
url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info"
url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
headers=headers,
)
r.raise_for_status()
return True
@@ -351,7 +361,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
if model_node_id:
model_list_key = None
print(workflow[model_node_id]["class_type"])
log.info(workflow[model_node_id]["class_type"])
for key in info[workflow[model_node_id]["class_type"]]["input"][
"required"
]:
@@ -507,7 +517,11 @@ async def image_generations(
images = []
for image in res["data"]:
image_data, content_type = load_b64_image_data(image["b64_json"])
if image_url := image.get("url", None):
image_data, content_type = load_url_image_data(image_url, headers)
else:
image_data, content_type = load_b64_image_data(image["b64_json"])
url = upload_image(request, data, image_data, content_type, user)
images.append({"url": url})
return images
+18 -8
View File
@@ -437,14 +437,24 @@ def remove_file_from_knowledge_by_id(
)
# Remove content from the vector database
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# 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)
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)
@@ -614,7 +624,7 @@ def add_files_to_knowledge_batch(
)
# Get files content
print(f"files/batch/add - {len(form_data)} files")
log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = []
for form in form_data:
file = Files.get_file_by_id(form.file_id)
+8 -4
View File
@@ -57,7 +57,9 @@ async def add_memory(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": {"created_at": memory.created_at},
}
],
@@ -82,7 +84,7 @@ async def query_memory(
):
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)],
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
limit=form_data.k,
)
@@ -105,7 +107,9 @@ async def reset_memory_from_vector_db(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
@@ -161,7 +165,7 @@ async def update_memory_by_id(
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user
memory.content, user=user
),
"metadata": {
"created_at": memory.created_at,
+159 -31
View File
@@ -14,6 +14,11 @@ from urllib.parse import urlparse
import aiohttp
from aiocache import cached
import requests
from open_webui.models.users import UserModel
from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from fastapi import (
Depends,
@@ -50,7 +55,7 @@ from open_webui.env import (
ENV,
SRC_LOG_LEVELS,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL,
)
from open_webui.constants import ERROR_MESSAGES
@@ -66,12 +71,26 @@ log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
##########################################
async def send_get_request(url, key=None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
async def send_get_request(url, key=None, user: UserModel = None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
url, headers={**({"Authorization": f"Bearer {key}"} if key else {})}
url,
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
) as response:
return await response.json()
except Exception as e:
@@ -96,6 +115,7 @@ async def send_post_request(
stream: bool = True,
key: Optional[str] = None,
content_type: Optional[str] = None,
user: UserModel = None,
):
r = None
@@ -110,6 +130,16 @@ async def send_post_request(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
)
r.raise_for_status()
@@ -186,12 +216,24 @@ async def verify_connection(
key = form_data.key
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
) as session:
try:
async with session.get(
f"{url}/api/version",
headers={**({"Authorization": f"Bearer {key}"} if key else {})},
headers={
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
) as r:
if r.status != 200:
detail = f"HTTP Error: {r.status}"
@@ -253,8 +295,8 @@ async def update_config(
}
@cached(ttl=3)
async def get_all_models(request: Request):
@cached(ttl=1)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = []
@@ -262,7 +304,7 @@ async def get_all_models(request: Request):
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/tags"))
request_tasks.append(send_get_request(f"{url}/api/tags", user=user))
else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
@@ -275,7 +317,9 @@ async def get_all_models(request: Request):
key = api_config.get("key", None)
if enable:
request_tasks.append(send_get_request(f"{url}/api/tags", key))
request_tasks.append(
send_get_request(f"{url}/api/tags", key, user=user)
)
else:
request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
@@ -292,6 +336,7 @@ async def get_all_models(request: Request):
)
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
model_ids = api_config.get("model_ids", [])
if len(model_ids) != 0 and "models" in response:
@@ -306,6 +351,10 @@ async def get_all_models(request: Request):
for model in response.get("models", []):
model["model"] = f"{prefix_id}.{model['model']}"
if tags:
for model in response.get("models", []):
model["tags"] = tags
def merge_models_lists(model_lists):
merged_models = {}
@@ -360,7 +409,7 @@ async def get_ollama_tags(
models = []
if url_idx is None:
models = await get_all_models(request)
models = await get_all_models(request, user=user)
else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
@@ -370,7 +419,19 @@ async def get_ollama_tags(
r = requests.request(
method="GET",
url=f"{url}/api/tags",
headers={**({"Authorization": f"Bearer {key}"} if key else {})},
headers={
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
)
r.raise_for_status()
@@ -404,18 +465,27 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
if request.app.state.config.ENABLE_OLLAMA_API:
if url_idx is None:
# returns lowest version
request_tasks = [
send_get_request(
f"{url}/api/version",
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
url, {}
), # Legacy support
)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
enable = api_config.get("enable", True)
key = api_config.get("key", None)
if enable:
request_tasks.append(
send_get_request(
f"{url}/api/version",
key,
)
)
responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses))
@@ -477,6 +547,7 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
url, {}
), # Legacy support
).get("key", None),
user=user,
)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
@@ -509,6 +580,7 @@ async def pull_model(
url=f"{url}/api/pull",
payload=json.dumps(payload),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -527,7 +599,7 @@ async def push_model(
user=Depends(get_admin_user),
):
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.name in models:
@@ -545,6 +617,7 @@ async def push_model(
url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -571,6 +644,7 @@ async def create_model(
url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -588,7 +662,7 @@ async def copy_model(
user=Depends(get_admin_user),
):
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.source in models:
@@ -609,6 +683,16 @@ async def copy_model(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
data=form_data.model_dump_json(exclude_none=True).encode(),
)
@@ -643,7 +727,7 @@ async def delete_model(
user=Depends(get_admin_user),
):
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.name in models:
@@ -665,6 +749,16 @@ async def delete_model(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
)
r.raise_for_status()
@@ -693,7 +787,7 @@ async def delete_model(
async def show_model_info(
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
):
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.name not in models:
@@ -714,6 +808,16 @@ async def show_model_info(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
data=form_data.model_dump_json(exclude_none=True).encode(),
)
@@ -757,7 +861,7 @@ async def embed(
log.info(f"generate_ollama_batch_embeddings {form_data}")
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
@@ -783,6 +887,16 @@ async def embed(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
data=form_data.model_dump_json(exclude_none=True).encode(),
)
@@ -826,7 +940,7 @@ async def embeddings(
log.info(f"generate_ollama_embeddings {form_data}")
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
@@ -852,6 +966,16 @@ async def embeddings(
headers={
"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
data=form_data.model_dump_json(exclude_none=True).encode(),
)
@@ -901,7 +1025,7 @@ async def generate_completion(
user=Depends(get_verified_user),
):
if url_idx is None:
await get_all_models(request)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
@@ -931,6 +1055,7 @@ async def generate_completion(
url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -1053,13 +1178,14 @@ async def generate_chat_completion(
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
# payload["keep_alive"] = -1 # keep alive forever
return await send_post_request(
url=f"{url}/api/chat",
payload=json.dumps(payload),
stream=form_data.stream,
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson",
user=user,
)
@@ -1162,6 +1288,7 @@ async def generate_openai_completion(
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -1240,6 +1367,7 @@ async def generate_openai_chat_completion(
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
)
@@ -1253,7 +1381,7 @@ async def get_openai_models(
models = []
if url_idx is None:
model_list = await get_all_models(request)
model_list = await get_all_models(request, user=user)
models = [
{
"id": model["model"],
+81 -31
View File
@@ -22,10 +22,11 @@ from open_webui.config import (
)
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
BYPASS_MODEL_ACCESS_CONTROL,
)
from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENV, SRC_LOG_LEVELS
@@ -35,6 +36,9 @@ from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_model_system_prompt_to_body,
)
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
@@ -51,12 +55,25 @@ log.setLevel(SRC_LOG_LEVELS["OPENAI"])
##########################################
async def send_get_request(url, key=None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
async def send_get_request(url, key=None, user: UserModel = None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
url, headers={**({"Authorization": f"Bearer {key}"} if key else {})}
url,
headers={
**({"Authorization": f"Bearer {key}"} if key else {}),
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
) as response:
return await response.json()
except Exception as e:
@@ -84,9 +101,15 @@ def openai_o1_o3_handler(payload):
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
# Fix: O1 does not support the "system" parameter, Modify "system" to "user"
# Fix: o1 and o3 do not support the "system" role directly.
# For older models like "o1-mini" or "o1-preview", use role "user".
# For newer o1/o3 models, replace "system" with "developer".
if payload["messages"][0]["role"] == "system":
payload["messages"][0]["role"] = "user"
model_lower = payload["model"].lower()
if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"):
payload["messages"][0]["role"] = "user"
else:
payload["messages"][0]["role"] = "developer"
return payload
@@ -172,7 +195,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(body).hexdigest()
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech"
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
@@ -247,7 +270,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND)
async def get_all_models_responses(request: Request) -> list:
async def get_all_models_responses(request: Request, user: UserModel) -> list:
if not request.app.state.config.ENABLE_OPENAI_API:
return []
@@ -271,7 +294,9 @@ async def get_all_models_responses(request: Request) -> list:
):
request_tasks.append(
send_get_request(
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx]
f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx],
user=user,
)
)
else:
@@ -291,6 +316,7 @@ async def get_all_models_responses(request: Request) -> list:
send_get_request(
f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx],
user=user,
)
)
else:
@@ -327,6 +353,7 @@ async def get_all_models_responses(request: Request) -> list:
)
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
if prefix_id:
for model in (
@@ -334,6 +361,12 @@ async def get_all_models_responses(request: Request) -> list:
):
model["id"] = f"{prefix_id}.{model['id']}"
if tags:
for model in (
response if isinstance(response, list) else response.get("data", [])
):
model["tags"] = tags
log.debug(f"get_all_models:responses() {responses}")
return responses
@@ -351,14 +384,14 @@ async def get_filtered_models(models, user):
return filtered_models
@cached(ttl=3)
async def get_all_models(request: Request) -> dict[str, list]:
@cached(ttl=1)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
if not request.app.state.config.ENABLE_OPENAI_API:
return {"data": []}
responses = await get_all_models_responses(request)
responses = await get_all_models_responses(request, user=user)
def extract_data(response):
if response and "data" in response:
@@ -373,6 +406,7 @@ async def get_all_models(request: Request) -> dict[str, list]:
for idx, models in enumerate(model_lists):
if models is not None and "error" not in models:
merged_list.extend(
[
{
@@ -383,18 +417,21 @@ async def get_all_models(request: Request) -> dict[str, list]:
"urlIdx": idx,
}
for model in models
if "api.openai.com"
not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
or not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
if (model.get("id") or model.get("name"))
and (
"api.openai.com"
not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
or not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
)
)
]
)
@@ -418,16 +455,14 @@ async def get_models(
}
if url_idx is None:
models = await get_all_models(request)
models = await get_all_models(request, user=user)
else:
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
key = request.app.state.config.OPENAI_API_KEYS[url_idx]
r = None
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(
total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST
)
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
) as session:
try:
async with session.get(
@@ -507,7 +542,7 @@ async def verify_connection(
key = form_data.key
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST)
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
) as session:
try:
async with session.get(
@@ -515,6 +550,16 @@ async def verify_connection(
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
) as r:
if r.status != 200:
@@ -587,7 +632,7 @@ async def generate_chat_completion(
detail="Model not found",
)
await get_all_models(request)
await get_all_models(request, user=user)
model = request.app.state.OPENAI_MODELS.get(model_id)
if model:
idx = model["urlIdx"]
@@ -635,6 +680,11 @@ async def generate_chat_completion(
del payload["max_tokens"]
# Convert the modified body back to JSON
if "logit_bias" in payload:
payload["logit_bias"] = json.loads(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
payload = json.dumps(payload)
r = None
@@ -777,7 +827,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
if r is not None:
try:
res = await r.json()
print(res)
log.error(res)
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
+13 -13
View File
@@ -90,8 +90,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e:
res = (
await response.json()
@@ -101,7 +101,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
if "detail" in res:
raise Exception(response.status, res["detail"])
except Exception as e:
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
return payload
@@ -139,8 +139,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e:
try:
res = (
@@ -153,7 +153,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
except Exception:
pass
except Exception as e:
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
return payload
@@ -169,7 +169,7 @@ router = APIRouter()
@router.get("/list")
async def get_pipelines_list(request: Request, user=Depends(get_admin_user)):
responses = await get_all_models_responses(request)
responses = await get_all_models_responses(request, user)
log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}")
urlIdxs = [
@@ -196,7 +196,7 @@ async def upload_pipeline(
file: UploadFile = File(...),
user=Depends(get_admin_user),
):
print("upload_pipeline", urlIdx, file.filename)
log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}")
# Check if the uploaded file is a python file
if not (file.filename and file.filename.endswith(".py")):
raise HTTPException(
@@ -231,7 +231,7 @@ async def upload_pipeline(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
status_code = status.HTTP_404_NOT_FOUND
@@ -282,7 +282,7 @@ async def add_pipeline(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
@@ -327,7 +327,7 @@ async def delete_pipeline(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
@@ -361,7 +361,7 @@ async def get_pipelines(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
@@ -400,7 +400,7 @@ async def get_pipeline_valves(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
@@ -440,7 +440,7 @@ async def get_pipeline_valves_spec(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
@@ -482,7 +482,7 @@ async def update_pipeline_valves(
return {**data}
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
log.exception(f"Connection error: {e}")
detail = None
+156 -53
View File
@@ -59,7 +59,7 @@ from open_webui.retrieval.web.serpstack import search_serpstack
from open_webui.retrieval.web.tavily import search_tavily
from open_webui.retrieval.web.bing import search_bing
from open_webui.retrieval.web.exa import search_exa
from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.utils import (
get_embedding_function,
@@ -74,7 +74,6 @@ from open_webui.utils.misc import (
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import (
ENV,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
@@ -83,6 +82,8 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
UPLOAD_DIR,
DEFAULT_LOCALE,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_QUERY_PREFIX,
)
from open_webui.env import (
SRC_LOG_LEVELS,
@@ -352,10 +353,17 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT,
"BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL,
"enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
},
"chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER,
@@ -373,10 +381,11 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
},
"web": {
"ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"onedrive": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
"searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL,
"google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY,
@@ -398,7 +407,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"exa_api_key": request.app.state.config.EXA_API_KEY,
"perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY,
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
"trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
"domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
},
@@ -411,9 +422,16 @@ class FileConfig(BaseModel):
max_count: Optional[int] = None
class DocumentIntelligenceConfigForm(BaseModel):
endpoint: str
key: str
class ContentExtractionConfig(BaseModel):
engine: str = ""
tika_server_url: Optional[str] = None
docling_server_url: Optional[str] = None
document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None
class ChunkParamUpdateForm(BaseModel):
@@ -451,6 +469,7 @@ class WebSearchConfig(BaseModel):
bing_search_v7_endpoint: Optional[str] = None
bing_search_v7_subscription_key: Optional[str] = None
exa_api_key: Optional[str] = None
perplexity_api_key: Optional[str] = None
result_count: Optional[int] = None
concurrent_requests: Optional[int] = None
trust_env: Optional[bool] = None
@@ -460,13 +479,15 @@ class WebSearchConfig(BaseModel):
class WebConfig(BaseModel):
search: WebSearchConfig
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
RAG_WEB_SEARCH_FULL_CONTEXT: Optional[bool] = None
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
class ConfigUpdateForm(BaseModel):
RAG_FULL_CONTEXT: Optional[bool] = None
BYPASS_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
pdf_extract_images: Optional[bool] = None
enable_google_drive_integration: Optional[bool] = None
enable_onedrive_integration: Optional[bool] = None
file: Optional[FileConfig] = None
content_extraction: Optional[ContentExtractionConfig] = None
chunk: Optional[ChunkParamUpdateForm] = None
@@ -490,24 +511,48 @@ async def update_rag_config(
else request.app.state.config.RAG_FULL_CONTEXT
)
request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = (
form_data.BYPASS_EMBEDDING_AND_RETRIEVAL
if form_data.BYPASS_EMBEDDING_AND_RETRIEVAL is not None
else request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = (
form_data.enable_google_drive_integration
if form_data.enable_google_drive_integration is not None
else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION
)
request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION = (
form_data.enable_onedrive_integration
if form_data.enable_onedrive_integration is not None
else request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION
)
if form_data.file is not None:
request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size
request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count
if form_data.content_extraction is not None:
log.info(f"Updating text settings: {form_data.content_extraction}")
log.info(
f"Updating content extraction: {request.app.state.config.CONTENT_EXTRACTION_ENGINE} to {form_data.content_extraction.engine}"
)
request.app.state.config.CONTENT_EXTRACTION_ENGINE = (
form_data.content_extraction.engine
)
request.app.state.config.TIKA_SERVER_URL = (
form_data.content_extraction.tika_server_url
)
request.app.state.config.DOCLING_SERVER_URL = (
form_data.content_extraction.docling_server_url
)
if form_data.content_extraction.document_intelligence_config is not None:
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.content_extraction.document_intelligence_config.endpoint
)
request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = (
form_data.content_extraction.document_intelligence_config.key
)
if form_data.chunk is not None:
request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter
@@ -528,8 +573,8 @@ async def update_rag_config(
request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled
request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine
request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = (
form_data.web.RAG_WEB_SEARCH_FULL_CONTEXT
request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.SEARXNG_QUERY_URL = (
@@ -580,6 +625,10 @@ async def update_rag_config(
request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key
request.app.state.config.PERPLEXITY_API_KEY = (
form_data.web.search.perplexity_api_key
)
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = (
form_data.web.search.result_count
)
@@ -597,6 +646,7 @@ async def update_rag_config(
"status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT,
"BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL,
"file": {
"max_size": request.app.state.config.FILE_MAX_SIZE,
"max_count": request.app.state.config.FILE_MAX_COUNT,
@@ -604,6 +654,11 @@ async def update_rag_config(
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
},
"chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER,
@@ -617,7 +672,7 @@ async def update_rag_config(
},
"web": {
"ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
@@ -641,6 +696,7 @@ async def update_rag_config(
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"exa_api_key": request.app.state.config.EXA_API_KEY,
"perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY,
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
"trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
@@ -664,6 +720,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
"status": True,
"template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
}
@@ -671,6 +728,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
class QuerySettingsForm(BaseModel):
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
template: Optional[str] = None
hybrid: Optional[bool] = None
@@ -682,6 +740,7 @@ async def update_query_settings(
):
request.app.state.config.RAG_TEMPLATE = form_data.template
request.app.state.config.TOP_K = form_data.k if form_data.k else 4
request.app.state.config.TOP_K_RERANKER = form_data.k_reranker or 4
request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = (
@@ -692,6 +751,7 @@ async def update_query_settings(
"status": True,
"template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
}
@@ -832,7 +892,9 @@ def save_docs_to_vector_db(
)
embeddings = embedding_function(
list(map(lambda x: x.replace("\n", " "), texts)), user=user
list(map(lambda x: x.replace("\n", " "), texts)),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
items = [
@@ -880,7 +942,12 @@ def process_file(
# Update the content in the file
# Usage: /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
try:
# /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
except:
# Audio file upload pipeline
pass
docs = [
Document(
@@ -936,7 +1003,10 @@ def process_file(
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
@@ -979,36 +1049,45 @@ def process_file(
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
except Exception as e:
raise e
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
except Exception as e:
raise e
else:
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
except Exception as e:
log.exception(e)
if "No pandoc was found" in str(e):
@@ -1124,9 +1203,13 @@ def process_web(
content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {content}")
save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user
)
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user
)
else:
collection_name = None
return {
"status": True,
@@ -1138,6 +1221,7 @@ def process_web(
},
"meta": {
"name": form_data.url,
"source": form_data.url,
},
},
}
@@ -1163,6 +1247,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
- SERPLY_API_KEY
- TAVILY_API_KEY
- EXA_API_KEY
- PERPLEXITY_API_KEY
- SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`)
- SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`)
Args:
@@ -1327,6 +1412,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "perplexity":
return search_perplexity(
request.app.state.config.PERPLEXITY_API_KEY,
query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -1368,9 +1460,11 @@ async def process_web_search(
)
docs = await loader.aload()
if request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT:
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
"status": True,
"collection_name": None,
"filenames": urls,
"docs": [
{
"content": doc.page_content,
@@ -1378,7 +1472,6 @@ async def process_web_search(
}
for doc in docs
],
"filenames": urls,
"loaded_count": len(docs),
}
else:
@@ -1409,6 +1502,7 @@ class QueryDocForm(BaseModel):
collection_name: str
query: str
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
hybrid: Optional[bool] = None
@@ -1424,11 +1518,13 @@ def query_doc_handler(
return query_doc_with_hybrid_search(
collection_name=form_data.collection_name,
query=form_data.query,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
form_data.r
if form_data.r
@@ -1440,7 +1536,7 @@ def query_doc_handler(
return query_doc(
collection_name=form_data.collection_name,
query_embedding=request.app.state.EMBEDDING_FUNCTION(
form_data.query, user=user
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user,
@@ -1457,6 +1553,7 @@ class QueryCollectionsForm(BaseModel):
collection_names: list[str]
query: str
k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None
hybrid: Optional[bool] = None
@@ -1472,11 +1569,13 @@ def query_collection_handler(
return query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
form_data.r
if form_data.r
@@ -1487,8 +1586,8 @@ def query_collection_handler(
return query_collection(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
)
@@ -1553,11 +1652,11 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool:
elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory
except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}")
log.exception(f"Failed to delete {file_path}. Reason: {e}")
else:
print(f"The directory {folder} does not exist")
log.warning(f"The directory {folder} does not exist")
except Exception as e:
print(f"Failed to process the directory {folder}. Reason: {e}")
log.exception(f"Failed to process the directory {folder}. Reason: {e}")
return True
@@ -1565,7 +1664,11 @@ if ENV == "dev":
@router.get("/ef/{text}")
async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"):
return {"result": request.app.state.EMBEDDING_FUNCTION(text)}
return {
"result": request.app.state.EMBEDDING_FUNCTION(
text, prefix=RAG_EMBEDDING_QUERY_PREFIX
)
}
class BatchProcessFilesForm(BaseModel):
+46
View File
@@ -20,6 +20,10 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.constants import TASKS
from open_webui.routers.pipelines import process_pipeline_inlet_filter
from open_webui.utils.filter import (
get_sorted_filter_ids,
process_filter_functions,
)
from open_webui.utils.task import get_task_model_id
from open_webui.config import (
@@ -221,6 +225,12 @@ async def generate_title(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -290,6 +300,12 @@ async def generate_chat_tags(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -356,6 +372,12 @@ async def generate_image_prompt(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -433,6 +455,12 @@ async def generate_queries(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -514,6 +542,12 @@ async def generate_autocompletion(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -584,6 +618,12 @@ async def generate_emoji(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
@@ -644,6 +684,12 @@ async def generate_moa_response(
},
}
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
+10 -5
View File
@@ -1,3 +1,4 @@
import logging
from pathlib import Path
from typing import Optional
@@ -15,6 +16,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.tools import get_tools_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
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
@@ -100,7 +105,7 @@ async def create_new_tools(
specs = get_tools_specs(TOOLS[form_data.id])
tools = Tools.insert_new_tool(user.id, form_data, specs)
tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id
tool_cache_dir = CACHE_DIR / "tools" / form_data.id
tool_cache_dir.mkdir(parents=True, exist_ok=True)
if tools:
@@ -111,7 +116,7 @@ async def create_new_tools(
detail=ERROR_MESSAGES.DEFAULT("Error creating tools"),
)
except Exception as e:
print(e)
log.exception(f"Failed to load the tool by id {form_data.id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
@@ -193,7 +198,7 @@ async def update_tools_by_id(
"specs": specs,
}
print(updated)
log.debug(updated)
tools = Tools.update_tool_by_id(id, updated)
if tools:
@@ -343,7 +348,7 @@ async def update_tools_valves_by_id(
Tools.update_tool_valves_by_id(id, valves.model_dump())
return valves.model_dump()
except Exception as e:
print(e)
log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
@@ -421,7 +426,7 @@ async def update_tools_user_valves_by_id(
)
return user_valves.model_dump()
except Exception as e:
print(e)
log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
+25 -5
View File
@@ -2,6 +2,7 @@ import logging
from typing import Optional
from open_webui.models.auths import Auths
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
UserModel,
@@ -17,7 +18,10 @@ 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.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -45,7 +49,7 @@ async def get_users(
@router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
return Groups.get_groups_by_member_id(user.id)
############################
@@ -54,8 +58,12 @@ async def get_user_groups(user=Depends(get_verified_user)):
@router.get("/permissions")
async def get_user_permissisions(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id)
async def get_user_permissisions(request: Request, user=Depends(get_verified_user)):
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return user_permissions
############################
@@ -68,12 +76,20 @@ class WorkspacePermissions(BaseModel):
tools: bool = False
class SharingPermissions(BaseModel):
public_models: bool = True
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
file_upload: bool = True
delete: bool = True
edit: bool = True
temporary: bool = True
temporary_enforced: bool = False
class FeaturesPermissions(BaseModel):
@@ -84,16 +100,20 @@ class FeaturesPermissions(BaseModel):
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
sharing: SharingPermissions
chat: ChatPermissions
features: FeaturesPermissions
@router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
return {
"workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
),
"sharing": SharingPermissions(
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
@@ -104,7 +124,7 @@ async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
@router.post("/default/permissions")
async def update_user_permissions(
async def update_default_user_permissions(
request: Request, form_data: UserPermissions, user=Depends(get_admin_user)
):
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
+6 -1
View File
@@ -1,4 +1,5 @@
import black
import logging
import markdown
from open_webui.models.chats import ChatTitleMessagesForm
@@ -13,8 +14,12 @@ from open_webui.utils.misc import get_gravatar_url
from open_webui.utils.pdf_generator import PDFGenerator
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
@@ -96,7 +101,7 @@ async def download_chat_as_pdf(
headers={"Content-Disposition": "attachment;filename=chat.pdf"},
)
except Exception as e:
print(e)
log.exception(f"Error generating PDF: {e}")
raise HTTPException(status_code=400, detail=str(e))
+82 -36
View File
@@ -3,15 +3,24 @@ import socketio
import logging
import sys
import time
from redis import asyncio as aioredis
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.utils.redis import (
parse_redis_sentinel_url,
get_sentinels_from_env,
AsyncRedisSentinelManager,
)
from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock
@@ -28,7 +37,19 @@ log.setLevel(SRC_LOG_LEVELS["SOCKET"])
if WEBSOCKET_MANAGER == "redis":
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
if WEBSOCKET_SENTINEL_HOSTS:
redis_config = parse_redis_sentinel_url(WEBSOCKET_REDIS_URL)
mgr = AsyncRedisSentinelManager(
WEBSOCKET_SENTINEL_HOSTS.split(","),
sentinel_port=int(WEBSOCKET_SENTINEL_PORT),
redis_port=redis_config["port"],
service=redis_config["service"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
)
else:
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
sio = socketio.AsyncServer(
cors_allowed_origins=[],
async_mode="asgi",
@@ -54,14 +75,30 @@ TIMEOUT_DURATION = 3
if WEBSOCKET_MANAGER == "redis":
log.debug("Using Redis to manage websockets.")
SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL)
USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL)
USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL)
redis_sentinels = get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
SESSION_POOL = RedisDict(
"open-webui:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USER_POOL = RedisDict(
"open-webui:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USAGE_POOL = RedisDict(
"open-webui:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock",
timeout_secs=TIMEOUT_DURATION * 2,
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
@@ -268,11 +305,19 @@ async def disconnect(sid):
# print(f"Unknown session ID {sid} disconnected")
def get_event_emitter(request_info):
def get_event_emitter(request_info, update_db=True):
async def __event_emitter__(event_data):
user_id = request_info["user_id"]
session_ids = list(
set(USER_POOL.get(user_id, []) + [request_info["session_id"]])
set(
USER_POOL.get(user_id, [])
+ (
[request_info.get("session_id")]
if request_info.get("session_id")
else []
)
)
)
for session_id in session_ids:
@@ -286,40 +331,41 @@ def get_event_emitter(request_info):
to=session_id,
)
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if update_db:
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
return __event_emitter__
+9 -5
View File
@@ -1,15 +1,17 @@
import json
import redis
import uuid
from open_webui.utils.redis import get_redis_connection
class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs):
def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def aquire_lock(self):
# nx=True will only set this key if it _hasn't_ already been set
@@ -31,9 +33,11 @@ class RedisLock:
class RedisDict:
def __init__(self, name, redis_url):
def __init__(self, name, redis_url, redis_sentinels=[]):
self.name = name
self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def __setitem__(self, key, value):
serialized_value = json.dumps(value)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

@@ -3,13 +3,13 @@
"short_name": "WebUI",
"icons": [
{
"src": "/favicon/web-app-manifest-192x192.png",
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/web-app-manifest-512x512.png",
"src": "/static/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+38 -9
View File
@@ -1,10 +1,12 @@
import os
import shutil
import json
import logging
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from open_webui.config import (
S3_ACCESS_KEY_ID,
@@ -13,6 +15,8 @@ from open_webui.config import (
S3_KEY_PREFIX,
S3_REGION_NAME,
S3_SECRET_ACCESS_KEY,
S3_USE_ACCELERATE_ENDPOINT,
S3_ADDRESSING_STYLE,
GCS_BUCKET_NAME,
GOOGLE_APPLICATION_CREDENTIALS_JSON,
AZURE_STORAGE_ENDPOINT,
@@ -27,6 +31,11 @@ from open_webui.constants import ERROR_MESSAGES
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
from azure.core.exceptions import ResourceNotFoundError
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
class StorageProvider(ABC):
@@ -71,7 +80,7 @@ class LocalStorageProvider(StorageProvider):
if os.path.isfile(file_path):
os.remove(file_path)
else:
print(f"File {file_path} not found in local storage.")
log.warning(f"File {file_path} not found in local storage.")
@staticmethod
def delete_all_files() -> None:
@@ -85,20 +94,40 @@ class LocalStorageProvider(StorageProvider):
elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory
except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}")
log.exception(f"Failed to delete {file_path}. Reason: {e}")
else:
print(f"Directory {UPLOAD_DIR} not found in local storage.")
log.warning(f"Directory {UPLOAD_DIR} not found in local storage.")
class S3StorageProvider(StorageProvider):
def __init__(self):
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
config = Config(
s3={
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
"addressing_style": S3_ADDRESSING_STYLE,
},
)
# If access key and secret are provided, use them for authentication
if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY:
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
config=config,
)
else:
# If no explicit credentials are provided, fall back to default AWS credentials
# This supports workload identity (IAM roles for EC2, EKS, etc.)
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
config=config,
)
self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
@@ -187,6 +187,17 @@ class TestS3StorageProvider:
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
def test_init_without_credentials(self, monkeypatch):
"""Test that S3StorageProvider can initialize without explicit credentials."""
# Temporarily unset the environment variables
monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None)
monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None)
# Should not raise an exception
storage = provider.S3StorageProvider()
assert storage.s3_client is not None
assert storage.bucket_name == provider.S3_BUCKET_NAME
class TestGCSStorageProvider:
Storage = provider.GCSStorageProvider()
+249
View File
@@ -0,0 +1,249 @@
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from enum import Enum
import re
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
MutableMapping,
Optional,
cast,
)
import uuid
from asgiref.typing import (
ASGI3Application,
ASGIReceiveCallable,
ASGIReceiveEvent,
ASGISendCallable,
ASGISendEvent,
Scope as ASGIScope,
)
from loguru import logger
from starlette.requests import Request
from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
from open_webui.models.users import UserModel
if TYPE_CHECKING:
from loguru import Logger
@dataclass(frozen=True)
class AuditLogEntry:
# `Metadata` audit level properties
id: str
user: dict[str, Any]
audit_level: str
verb: str
request_uri: str
user_agent: Optional[str] = None
source_ip: Optional[str] = None
# `Request` audit level properties
request_object: Any = None
# `Request Response` level
response_object: Any = None
response_status_code: Optional[int] = None
class AuditLevel(str, Enum):
NONE = "NONE"
METADATA = "METADATA"
REQUEST = "REQUEST"
REQUEST_RESPONSE = "REQUEST_RESPONSE"
class AuditLogger:
"""
A helper class that encapsulates audit logging functionality. It uses Loguru’s logger with an auditable binding to ensure that audit log entries are filtered correctly.
Parameters:
logger (Logger): An instance of Loguru’s logger.
"""
def __init__(self, logger: "Logger"):
self.logger = logger.bind(auditable=True)
def write(
self,
audit_entry: AuditLogEntry,
*,
log_level: str = "INFO",
extra: Optional[dict] = None,
):
entry = asdict(audit_entry)
if extra:
entry["extra"] = extra
self.logger.log(
log_level,
"",
**entry,
)
class AuditContext:
"""
Captures and aggregates the HTTP request and response bodies during the processing of a request. It ensures that only a configurable maximum amount of data is stored to prevent excessive memory usage.
Attributes:
request_body (bytearray): Accumulated request payload.
response_body (bytearray): Accumulated response payload.
max_body_size (int): Maximum number of bytes to capture.
metadata (Dict[str, Any]): A dictionary to store additional audit metadata (user, http verb, user agent, etc.).
"""
def __init__(self, max_body_size: int = MAX_BODY_LOG_SIZE):
self.request_body = bytearray()
self.response_body = bytearray()
self.max_body_size = max_body_size
self.metadata: Dict[str, Any] = {}
def add_request_chunk(self, chunk: bytes):
if len(self.request_body) < self.max_body_size:
self.request_body.extend(
chunk[: self.max_body_size - len(self.request_body)]
)
def add_response_chunk(self, chunk: bytes):
if len(self.response_body) < self.max_body_size:
self.response_body.extend(
chunk[: self.max_body_size - len(self.response_body)]
)
class AuditLoggingMiddleware:
"""
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
"""
AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"}
def __init__(
self,
app: ASGI3Application,
*,
excluded_paths: Optional[list[str]] = None,
max_body_size: int = MAX_BODY_LOG_SIZE,
audit_level: AuditLevel = AuditLevel.NONE,
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.max_body_size = max_body_size
self.audit_level = audit_level
async def __call__(
self,
scope: ASGIScope,
receive: ASGIReceiveCallable,
send: ASGISendCallable,
) -> None:
if scope["type"] != "http":
return await self.app(scope, receive, send)
request = Request(scope=cast(MutableMapping, scope))
if self._should_skip_auditing(request):
return await self.app(scope, receive, send)
async with self._audit_context(request) as context:
async def send_wrapper(message: ASGISendEvent) -> None:
if self.audit_level == AuditLevel.REQUEST_RESPONSE:
await self._capture_response(message, context)
await send(message)
original_receive = receive
async def receive_wrapper() -> ASGIReceiveEvent:
nonlocal original_receive
message = await original_receive()
if self.audit_level in (
AuditLevel.REQUEST,
AuditLevel.REQUEST_RESPONSE,
):
await self._capture_request(message, context)
return message
await self.app(scope, receive_wrapper, send_wrapper)
@asynccontextmanager
async def _audit_context(
self, request: Request
) -> AsyncGenerator[AuditContext, None]:
"""
async context manager that ensures that an audit log entry is recorded after the request is processed.
"""
context = AuditContext()
try:
yield context
finally:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> UserModel:
auth_header = request.headers.get("Authorization")
assert auth_header
user = get_current_user(request, None, get_http_authorization_cred(auth_header))
return user
def _should_skip_auditing(self, request: Request) -> bool:
if (
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
or AUDIT_LOG_LEVEL == "NONE"
or not request.headers.get("authorization")
):
return True
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
pattern = re.compile(
r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
)
if pattern.match(request.url.path):
return True
return False
async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext):
if message["type"] == "http.request":
body = message.get("body", b"")
context.add_request_chunk(body)
async def _capture_response(self, message: ASGISendEvent, context: AuditContext):
if message["type"] == "http.response.start":
context.metadata["response_status_code"] = message["status"]
elif message["type"] == "http.response.body":
body = message.get("body", b"")
context.add_response_chunk(body)
async def _log_audit_entry(self, request: Request, context: AuditContext):
try:
user = await self._get_authenticated_user(request)
entry = AuditLogEntry(
id=str(uuid.uuid4()),
user=user.model_dump(include={"id", "name", "email", "role"}),
audit_level=self.audit_level.value,
verb=request.method,
request_uri=str(request.url),
response_status_code=context.metadata.get("response_status_code", None),
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
request_object=context.request_body.decode("utf-8", errors="replace"),
response_object=context.response_body.decode("utf-8", errors="replace"),
)
self.audit_logger.write(entry)
except Exception as e:
logger.error(f"Failed to log audit entry: {str(e)}")
+23 -10
View File
@@ -14,14 +14,22 @@ from typing import Optional, Union, List, Dict
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY, STATIC_DIR
from open_webui.env import (
WEBUI_SECRET_KEY,
TRUSTED_SIGNATURE_KEY,
STATIC_DIR,
SRC_LOG_LEVELS,
)
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext
logging.getLogger("passlib").setLevel(logging.ERROR)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OAUTH"])
SESSION_SECRET = WEBUI_SECRET_KEY
ALGORITHM = "HS256"
@@ -50,7 +58,7 @@ def verify_signature(payload: str, signature: str) -> bool:
def override_static(path: str, content: str):
# Ensure path is safe
if "/" in path or ".." in path:
print(f"Invalid path: {path}")
log.error(f"Invalid path: {path}")
return
file_path = os.path.join(STATIC_DIR, path)
@@ -64,7 +72,7 @@ def get_license_data(app, key):
if key:
try:
res = requests.post(
"https://api.openwebui.com/api/v1/license",
"https://api.openwebui.com/api/v1/license/",
json={"key": key, "version": "1"},
timeout=5,
)
@@ -75,18 +83,19 @@ def get_license_data(app, key):
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "user_count":
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "webui_name":
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
return True
else:
print(
log.error(
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
)
except Exception as ex:
print(f"License: Uncaught Exception: {ex}")
log.exception(f"License: Uncaught Exception: {ex}")
return False
@@ -142,6 +151,7 @@ def get_http_authorization_cred(auth_header: str):
def get_current_user(
request: Request,
background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
):
token = None
@@ -194,7 +204,10 @@ def get_current_user(
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
else:
Users.update_user_last_active_by_id(user.id)
# 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(
+12 -7
View File
@@ -66,7 +66,7 @@ async def generate_direct_chat_completion(
user: Any,
models: dict,
):
print("generate_direct_chat_completion")
log.info("generate_direct_chat_completion")
metadata = form_data.pop("metadata", {})
@@ -103,7 +103,7 @@ async def generate_direct_chat_completion(
}
)
print("res", res)
log.info(f"res: {res}")
if res.get("status", False):
# Define a generator to stream responses
@@ -149,7 +149,7 @@ async def generate_direct_chat_completion(
}
)
if "error" in res:
if "error" in res and res["error"]:
raise Exception(res["error"])
return res
@@ -285,7 +285,7 @@ chat_completion = generate_chat_completion
async def chat_completed(request: Request, form_data: dict, user: Any):
if not request.app.state.MODELS:
await get_all_models(request)
await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
@@ -328,9 +328,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
}
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
]
result, _ = await process_filter_functions(
request=request,
filter_ids=get_sorted_filter_ids(model),
filter_functions=filter_functions,
filter_type="outlet",
form_data=data,
extra_params=extra_params,
@@ -351,7 +356,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
raise Exception(f"Action not found: {action_id}")
if not request.app.state.MODELS:
await get_all_models(request)
await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
@@ -432,7 +437,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
)
)
except Exception as e:
print(e)
log.exception(f"Failed to get user values: {e}")
params = {**params, "__user__": __user__}
+188 -126
View File
@@ -1,148 +1,210 @@
import asyncio
import json
import logging
import uuid
from typing import Optional
import aiohttp
import websockets
import requests
from urllib.parse import urljoin
from pydantic import BaseModel
from open_webui.env import SRC_LOG_LEVELS
logger = logging.getLogger(__name__)
logger.setLevel(SRC_LOG_LEVELS["MAIN"])
async def execute_code_jupyter(
jupyter_url, code, token=None, password=None, timeout=10
):
class ResultModel(BaseModel):
"""
Executes Python code in a Jupyter kernel.
Supports authentication with a token or password.
:param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888")
:param code: Code to execute
:param token: Jupyter authentication token (optional)
:param password: Jupyter password (optional)
:param timeout: WebSocket timeout in seconds (default: 10s)
:return: Dictionary with stdout, stderr, and result
- Images are prefixed with "base64:image/png," and separated by newlines if multiple.
Execute Code Result Model
"""
session = requests.Session() # Maintain cookies
headers = {} # Headers for requests
# Authenticate using password
if password and not token:
stdout: Optional[str] = ""
stderr: Optional[str] = ""
result: Optional[str] = ""
class JupyterCodeExecuter:
"""
Execute code in jupyter notebook
"""
def __init__(
self,
base_url: str,
code: str,
token: str = "",
password: str = "",
timeout: int = 60,
):
"""
:param base_url: Jupyter server URL (e.g., "http://localhost:8888")
:param code: Code to execute
:param token: Jupyter authentication token (optional)
:param password: Jupyter password (optional)
:param timeout: WebSocket timeout in seconds (default: 60s)
"""
self.base_url = base_url.rstrip("/")
self.code = code
self.token = token
self.password = password
self.timeout = timeout
self.kernel_id = ""
self.session = aiohttp.ClientSession(base_url=self.base_url)
self.params = {}
self.result = ResultModel()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.kernel_id:
try:
async with self.session.delete(
f"/api/kernels/{self.kernel_id}", params=self.params
) as response:
response.raise_for_status()
except Exception as err:
logger.exception("close kernel failed, %s", err)
await self.session.close()
async def run(self) -> ResultModel:
try:
login_url = urljoin(jupyter_url, "/login")
response = session.get(login_url)
await self.sign_in()
await self.init_kernel()
await self.execute_code()
except Exception as err:
logger.exception("execute code failed, %s", err)
self.result.stderr = f"Error: {err}"
return self.result
async def sign_in(self) -> None:
# password authentication
if self.password and not self.token:
async with self.session.get("/login") as response:
response.raise_for_status()
xsrf_token = response.cookies["_xsrf"].value
if not xsrf_token:
raise ValueError("_xsrf token not found")
self.session.cookie_jar.update_cookies(response.cookies)
self.session.headers.update({"X-XSRFToken": xsrf_token})
async with self.session.post(
"/login",
data={"_xsrf": xsrf_token, "password": self.password},
allow_redirects=False,
) as response:
response.raise_for_status()
self.session.cookie_jar.update_cookies(response.cookies)
# token authentication
if self.token:
self.params.update({"token": self.token})
async def init_kernel(self) -> None:
async with self.session.post(
url="/api/kernels", params=self.params
) as response:
response.raise_for_status()
xsrf_token = session.cookies.get("_xsrf")
if not xsrf_token:
raise ValueError("Failed to fetch _xsrf token")
login_data = {"_xsrf": xsrf_token, "password": password}
login_response = session.post(
login_url, data=login_data, cookies=session.cookies
)
login_response.raise_for_status()
headers["X-XSRFToken"] = xsrf_token
except Exception as e:
return {
"stdout": "",
"stderr": f"Authentication Error: {str(e)}",
"result": "",
}
# Construct API URLs with authentication token if provided
params = f"?token={token}" if token else ""
kernel_url = urljoin(jupyter_url, f"/api/kernels{params}")
try:
response = session.post(kernel_url, headers=headers, cookies=session.cookies)
response.raise_for_status()
kernel_id = response.json()["id"]
websocket_url = urljoin(
jupyter_url.replace("http", "ws"),
f"/api/kernels/{kernel_id}/channels{params}",
)
kernel_data = await response.json()
self.kernel_id = kernel_data["id"]
def init_ws(self) -> (str, dict):
ws_base = self.base_url.replace("http", "ws")
ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()])
websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}"
ws_headers = {}
if password and not token:
ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf")
cookies = {name: value for name, value in session.cookies.items()}
ws_headers["Cookie"] = "; ".join(
[f"{name}={value}" for name, value in cookies.items()]
)
if self.password and not self.token:
ws_headers = {
"Cookie": "; ".join(
[
f"{cookie.key}={cookie.value}"
for cookie in self.session.cookie_jar
]
),
**self.session.headers,
}
return websocket_url, ws_headers
async def execute_code(self) -> None:
# initialize ws
websocket_url, ws_headers = self.init_ws()
# execute
async with websockets.connect(
websocket_url, additional_headers=ws_headers
) as ws:
msg_id = str(uuid.uuid4())
execute_request = {
"header": {
"msg_id": msg_id,
"msg_type": "execute_request",
"username": "user",
"session": str(uuid.uuid4()),
"date": "",
"version": "5.3",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
},
"channel": "shell",
}
await ws.send(json.dumps(execute_request))
await self.execute_in_jupyter(ws)
stdout, stderr, result = "", "", []
while True:
try:
message = await asyncio.wait_for(ws.recv(), timeout)
message_data = json.loads(message)
if message_data.get("parent_header", {}).get("msg_id") == msg_id:
msg_type = message_data.get("msg_type")
if msg_type == "stream":
if message_data["content"]["name"] == "stdout":
stdout += message_data["content"]["text"]
elif message_data["content"]["name"] == "stderr":
stderr += message_data["content"]["text"]
elif msg_type in ("execute_result", "display_data"):
data = message_data["content"]["data"]
if "image/png" in data:
result.append(
f"data:image/png;base64,{data['image/png']}"
)
elif "text/plain" in data:
result.append(data["text/plain"])
elif msg_type == "error":
stderr += "\n".join(message_data["content"]["traceback"])
elif (
msg_type == "status"
and message_data["content"]["execution_state"] == "idle"
):
async def execute_in_jupyter(self, ws) -> None:
# send message
msg_id = uuid.uuid4().hex
await ws.send(
json.dumps(
{
"header": {
"msg_id": msg_id,
"msg_type": "execute_request",
"username": "user",
"session": uuid.uuid4().hex,
"date": "",
"version": "5.3",
},
"parent_header": {},
"metadata": {},
"content": {
"code": self.code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
},
"channel": "shell",
}
)
)
# parse message
stdout, stderr, result = "", "", []
while True:
try:
# wait for message
message = await asyncio.wait_for(ws.recv(), self.timeout)
message_data = json.loads(message)
# msg id not match, skip
if message_data.get("parent_header", {}).get("msg_id") != msg_id:
continue
# check message type
msg_type = message_data.get("msg_type")
match msg_type:
case "stream":
if message_data["content"]["name"] == "stdout":
stdout += message_data["content"]["text"]
elif message_data["content"]["name"] == "stderr":
stderr += message_data["content"]["text"]
case "execute_result" | "display_data":
data = message_data["content"]["data"]
if "image/png" in data:
result.append(f"data:image/png;base64,{data['image/png']}")
elif "text/plain" in data:
result.append(data["text/plain"])
case "error":
stderr += "\n".join(message_data["content"]["traceback"])
case "status":
if message_data["content"]["execution_state"] == "idle":
break
except asyncio.TimeoutError:
stderr += "\nExecution timed out."
break
except asyncio.TimeoutError:
stderr += "\nExecution timed out."
break
self.result.stdout = stdout.strip()
self.result.stderr = stderr.strip()
self.result.result = "\n".join(result).strip() if result else ""
except Exception as e:
return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""}
finally:
if kernel_id:
requests.delete(
f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies
)
return {
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"result": "\n".join(result).strip() if result else "",
}
async def execute_code_jupyter(
base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60
) -> dict:
async with JupyterCodeExecuter(
base_url, code, token, password, timeout
) as executor:
result = await executor.run()
return result.model_dump()
+25 -12
View File
@@ -1,9 +1,15 @@
import inspect
import logging
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.models.functions import Functions
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_sorted_filter_ids(model):
def get_sorted_filter_ids(model: dict):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None and hasattr(function, "valves"):
@@ -27,12 +33,13 @@ def get_sorted_filter_ids(model):
async def process_filter_functions(
request, filter_ids, filter_type, form_data, extra_params
request, filter_functions, filter_type, form_data, extra_params
):
skip_files = None
for filter_id in filter_ids:
filter = Functions.get_function_by_id(filter_id)
for function in filter_functions:
filter = function
filter_id = function.id
if not filter:
continue
@@ -42,6 +49,11 @@ async def process_filter_functions(
function_module, _, _ = load_function_module_by_id(filter_id)
request.app.state.FUNCTIONS[filter_id] = function_module
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
continue
# Check if the function has a file_handler variable
if filter_type == "inlet" and hasattr(function_module, "file_handler"):
skip_files = function_module.file_handler
@@ -53,15 +65,15 @@ async def process_filter_functions(
**(valves if valves else {})
)
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
continue
try:
# Prepare parameters
sig = inspect.signature(handler)
params = {"body": form_data} | {
params = {"body": form_data}
if filter_type == "stream":
params = {"event": form_data}
params = params | {
k: v
for k, v in {
**extra_params,
@@ -80,7 +92,7 @@ async def process_filter_functions(
)
)
except Exception as e:
print(e)
log.exception(f"Failed to get user values: {e}")
# Execute handler
if inspect.iscoroutinefunction(handler):
@@ -89,11 +101,12 @@ async def process_filter_functions(
form_data = handler(**params)
except Exception as e:
print(f"Error in {filter_type} handler {filter_id}: {e}")
log.debug(f"Error in {filter_type} handler {filter_id}: {e}")
raise e
# Handle file cleanup for inlet
if skip_files and "files" in form_data.get("metadata", {}):
del form_data["files"]
del form_data["metadata"]["files"]
return form_data, {}
+140
View File
@@ -0,0 +1,140 @@
import json
import logging
import sys
from typing import TYPE_CHECKING
from loguru import logger
from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
)
if TYPE_CHECKING:
from loguru import Record
def stdout_format(record: "Record") -> str:
"""
Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON).
Parameters:
record (Record): A Loguru record that contains logging details including time, level, name, function, line, message, and any extra context.
Returns:
str: A formatted log string intended for stdout.
"""
record["extra"]["extra_json"] = json.dumps(record["extra"])
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}"
)
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
and redirects them to Loguru's logger.
"""
def emit(self, record):
"""
Called by the standard logging module for each log event.
It transforms the standard `LogRecord` into a format compatible with Loguru
and passes it to Loguru's logger.
"""
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = sys._getframe(6), 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
def file_format(record: "Record"):
"""
Formats audit log records into a structured JSON string for file output.
Parameters:
record (Record): A Loguru record containing extra audit data.
Returns:
str: A JSON-formatted string representing the audit data.
"""
audit_data = {
"id": record["extra"].get("id", ""),
"timestamp": int(record["time"].timestamp()),
"user": record["extra"].get("user", dict()),
"audit_level": record["extra"].get("audit_level", ""),
"verb": record["extra"].get("verb", ""),
"request_uri": record["extra"].get("request_uri", ""),
"response_status_code": record["extra"].get("response_status_code", 0),
"source_ip": record["extra"].get("source_ip", ""),
"user_agent": record["extra"].get("user_agent", ""),
"request_object": record["extra"].get("request_object", b""),
"response_object": record["extra"].get("response_object", b""),
"extra": record["extra"].get("extra", {}),
}
record["extra"]["file_extra"] = json.dumps(audit_data, default=str)
return "{extra[file_extra]}\n"
def start_logger():
"""
Initializes and configures Loguru's logger with distinct handlers:
A console (stdout) handler for general log messages (excluding those marked as auditable).
An optional file handler for audit logs if audit logging is enabled.
Additionally, this function reconfigures Python’s standard logging to route through Loguru and adjusts logging levels for Uvicorn.
Parameters:
enable_audit_logging (bool): Determines whether audit-specific log entries should be recorded to file.
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
)
if AUDIT_LOG_LEVEL != "NONE":
try:
logger.add(
AUDIT_LOGS_FILE_PATH,
level="INFO",
rotation=AUDIT_LOG_FILE_ROTATION_SIZE,
compression="zip",
format=file_format,
filter=lambda record: record["extra"].get("auditable") is True,
)
except Exception as e:
logger.error(f"Failed to initialize audit log file handler: {str(e)}")
logging.basicConfig(
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
)
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = []
for uvicorn_logger_name in ["uvicorn.access"]:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [InterceptHandler()]
logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}")
+463 -198
View File
@@ -18,9 +18,7 @@ from uuid import uuid4
from concurrent.futures import ThreadPoolExecutor
from fastapi import Request
from fastapi import BackgroundTasks
from fastapi import Request, HTTPException
from starlette.responses import Response, StreamingResponse
@@ -68,6 +66,7 @@ from open_webui.utils.misc import (
get_last_user_message,
get_last_assistant_message,
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.plugin import load_function_module_by_id
@@ -99,7 +98,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def chat_completion_tools_handler(
request: Request, body: dict, user: UserModel, models, tools
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
async def get_content_from_response(response) -> Optional[str]:
content = None
@@ -134,6 +133,9 @@ async def chat_completion_tools_handler(
"metadata": {"task": str(TASKS.FUNCTION_CALLING)},
}
event_caller = extra_params["__event_call__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id(
body["model"],
request.app.state.config.TASK_MODEL,
@@ -155,7 +157,6 @@ async def chat_completion_tools_handler(
tools_function_calling_prompt = tools_function_calling_generation_template(
template, tools_specs
)
log.info(f"{tools_function_calling_prompt=}")
payload = get_tools_function_calling_payload(
body["messages"], task_model_id, tools_function_calling_prompt
)
@@ -188,34 +189,63 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {})
try:
required_params = (
tools[tool_function_name]
.get("spec", {})
.get("parameters", {})
.get("required", [])
tool = tools[tool_function_name]
spec = tool.get("spec", {})
allowed_params = (
spec.get("parameters", {}).get("properties", {}).keys()
)
tool_function = tools[tool_function_name]["callable"]
tool_function_params = {
k: v
for k, v in tool_function_params.items()
if k in required_params
if k in allowed_params
}
tool_output = await tool_function(**tool_function_params)
if tool.get("direct", False):
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get("session_id", None),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(**tool_function_params)
except Exception as e:
tool_output = str(e)
tool_result = str(e)
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2)
if isinstance(tool_result, str):
tool = tools[tool_function_name]
tool_id = tool.get("toolkit_id", "")
if tool.get("citation", False) or tool.get("direct", False):
if isinstance(tool_output, str):
if tools[tool_function_name]["citation"]:
sources.append(
{
"source": {
"name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"name": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
),
},
"document": [tool_output],
"document": [tool_result],
"metadata": [
{
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
}
],
}
@@ -224,16 +254,20 @@ async def chat_completion_tools_handler(
sources.append(
{
"source": {},
"document": [tool_output],
"document": [tool_result],
"metadata": [
{
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}"
"source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
}
],
}
)
if tools[tool_function_name]["file_handler"]:
if tools[tool_function_name].get("file_handler", False):
skip_files = True
# check if "tool_calls" in result
@@ -244,10 +278,10 @@ async def chat_completion_tools_handler(
await tool_call_handler(result)
except Exception as e:
log.exception(f"Error: {e}")
log.debug(f"Error: {e}")
content = None
except Exception as e:
log.exception(f"Error: {e}")
log.debug(f"Error: {e}")
content = None
log.debug(f"tool_contexts: {sources}")
@@ -351,24 +385,25 @@ async def chat_web_search_handler(
all_results.append(results)
files = form_data.get("files", [])
if request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT:
files.append(
{
"docs": results.get("docs", []),
"name": searchQuery,
"type": "web_search_docs",
"urls": results["filenames"],
}
)
else:
if results.get("collection_name"):
files.append(
{
"collection_name": results["collection_name"],
"name": searchQuery,
"type": "web_search_results",
"type": "web_search",
"urls": results["filenames"],
}
)
elif results.get("docs"):
files.append(
{
"docs": results.get("docs", []),
"name": searchQuery,
"type": "web_search",
"urls": results["filenames"],
}
)
form_data["files"] = files
except Exception as e:
log.exception(e)
@@ -518,6 +553,7 @@ async def chat_completion_files_handler(
sources = []
if files := body.get("metadata", {}).get("files", None):
queries = []
try:
queries_response = await generate_queries(
request,
@@ -543,8 +579,8 @@ async def chat_completion_files_handler(
queries_response = {"queries": [queries_response]}
queries = queries_response.get("queries", [])
except Exception as e:
queries = []
except:
pass
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
@@ -556,13 +592,15 @@ async def chat_completion_files_handler(
sources = await loop.run_in_executor(
executor,
lambda: get_sources_from_files(
request=request,
files=files,
queries=queries,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=request.app.state.config.RAG_FULL_CONTEXT,
@@ -607,11 +645,18 @@ def apply_params_to_form_data(form_data, model):
if "reasoning_effort" in params:
form_data["reasoning_effort"] = params["reasoning_effort"]
if "logit_bias" in params:
try:
form_data["logit_bias"] = json.loads(
convert_logit_bias_input_to_json(params["logit_bias"])
)
except Exception as e:
print(f"Error parsing logit_bias: {e}")
return form_data
async def process_chat_payload(request, form_data, metadata, user, model):
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}")
@@ -704,9 +749,14 @@ async def process_chat_payload(request, form_data, metadata, user, model):
raise e
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
]
form_data, flags = await process_filter_functions(
request=request,
filter_ids=get_sorted_filter_ids(model),
filter_functions=filter_functions,
filter_type="inlet",
form_data=form_data,
extra_params=extra_params,
@@ -738,6 +788,7 @@ async def process_chat_payload(request, form_data, metadata, user, model):
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
# Remove files duplicates
if files:
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
@@ -749,12 +800,18 @@ async def process_chat_payload(request, form_data, metadata, user, model):
}
form_data["metadata"] = metadata
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}")
tools_dict = {}
if tool_ids:
# If tool_ids field is present, then get the tools
tools = get_tools(
tools_dict = get_tools(
request,
tool_ids,
user,
@@ -765,20 +822,31 @@ async def process_chat_payload(request, form_data, metadata, user, model):
"__files__": metadata.get("files", []),
},
)
log.info(f"{tools=}")
if tool_servers:
for tool_server in tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
if tools_dict:
if metadata.get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools.values()
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
form_data, flags = await chat_completion_tools_handler(
request, form_data, user, models, tools
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
@@ -795,11 +863,9 @@ async def process_chat_payload(request, form_data, metadata, user, model):
if len(sources) > 0:
context_string = ""
for source_idx, source in enumerate(sources):
source_id = source.get("source", {}).get("name", "")
if "document" in source:
for doc_idx, doc_context in enumerate(source["document"]):
context_string += f"<source><source_id>{source_idx}</source_id><source_context>{doc_context}</source_context></source>\n"
context_string += f"<source><source_id>{source_idx + 1}</source_id><source_context>{doc_context}</source_context></source>\n"
context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"])
@@ -854,7 +920,7 @@ async def process_chat_payload(request, form_data, metadata, user, model):
async def process_chat_response(
request, response, form_data, user, events, metadata, tasks
request, response, form_data, user, metadata, model, events, tasks
):
async def background_tasks_handler():
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
@@ -976,6 +1042,16 @@ 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},
},
)
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
@@ -985,7 +1061,8 @@ async def process_chat_response(
},
)
if response.get("choices", [])[0].get("message", {}).get("content"):
choices = response.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if content:
@@ -1048,6 +1125,24 @@ async def process_chat_response(
):
return response
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_caller,
"__user__": {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
},
"__metadata__": metadata,
"__request__": request,
"__model__": model,
}
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
]
# Streaming response
if event_emitter and event_caller:
task_id = str(uuid4()) # Create a unique task ID.
@@ -1086,36 +1181,51 @@ async def process_chat_response(
elif block["type"] == "tool_calls":
attributes = block.get("attributes", {})
block_content = block.get("content", [])
tool_calls = block.get("content", [])
results = block.get("results", [])
if results:
result_display_content = ""
tool_calls_display_content = ""
for tool_call in tool_calls:
for result in results:
tool_call_id = result.get("tool_call_id", "")
tool_name = ""
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"arguments", ""
)
for tool_call in block_content:
if tool_call.get("id", "") == tool_call_id:
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_result = None
for result in results:
if tool_call_id == result.get("tool_call_id", ""):
tool_result = result.get("content", None)
break
result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}"
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))}">\n<summary>Tool Executed</summary>\n</details>'
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>'
if not raw:
content = f'{content}\n<details type="tool_calls" done="true" content="{html.escape(json.dumps(block_content))}" results="{html.escape(json.dumps(results))}">\n<summary>Tool Executed</summary>\n{result_display_content}\n</details>\n'
content = f"{content}\n{tool_calls_display_content}\n\n"
else:
tool_calls_display_content = ""
for tool_call in block_content:
tool_calls_display_content = f"{tool_calls_display_content}\n> Executing {tool_call.get('function', {}).get('name', '')}"
for tool_call in tool_calls:
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"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>'
if not raw:
content = f'{content}\n<details type="tool_calls" done="false" content="{html.escape(json.dumps(block_content))}">\n<summary>Tool Executing...</summary>\n{tool_calls_display_content}\n</details>\n'
content = f"{content}\n{tool_calls_display_content}\n\n"
elif block["type"] == "reasoning":
reasoning_display_content = "\n".join(
@@ -1127,12 +1237,12 @@ async def process_chat_response(
if reasoning_duration is not None:
if raw:
content = f'{content}\n<{block["tag"]}>{block["content"]}</{block["tag"]}>\n'
content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["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'
else:
if raw:
content = f'{content}\n<{block["tag"]}>{block["content"]}</{block["tag"]}>\n'
content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
else:
content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
@@ -1228,9 +1338,9 @@ async def process_chat_response(
return attributes
if content_blocks[-1]["type"] == "text":
for tag in tags:
for start_tag, end_tag in tags:
# Match start tag e.g., <tag> or <tag attr="value">
start_tag_pattern = rf"<{tag}(\s.*?)?>"
start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>"
match = re.search(start_tag_pattern, content)
if match:
attr_content = (
@@ -1263,7 +1373,8 @@ async def process_chat_response(
content_blocks.append(
{
"type": content_type,
"tag": tag,
"start_tag": start_tag,
"end_tag": end_tag,
"attributes": attributes,
"content": "",
"started_at": time.time(),
@@ -1275,9 +1386,10 @@ async def process_chat_response(
break
elif content_blocks[-1]["type"] == content_type:
tag = content_blocks[-1]["tag"]
start_tag = content_blocks[-1]["start_tag"]
end_tag = content_blocks[-1]["end_tag"]
# Match end tag e.g., </tag>
end_tag_pattern = rf"</{tag}>"
end_tag_pattern = rf"<{re.escape(end_tag)}>"
# Check if the content has the end tag
if re.search(end_tag_pattern, content):
@@ -1285,7 +1397,7 @@ async def process_chat_response(
block_content = content_blocks[-1]["content"]
# Strip start and end tags from the content
start_tag_pattern = rf"<{tag}(.*?)>"
start_tag_pattern = rf"<{re.escape(start_tag)}(.*?)>"
block_content = re.sub(
start_tag_pattern, "", block_content
).strip()
@@ -1350,7 +1462,7 @@ async def process_chat_response(
# Clean processed content
content = re.sub(
rf"<{tag}(.*?)>(.|\n)*?</{tag}>",
rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>",
"",
content,
flags=re.DOTALL,
@@ -1388,19 +1500,24 @@ async def process_chat_response(
# We might want to disable this by default
DETECT_REASONING = True
DETECT_SOLUTION = True
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
"code_interpreter", False
)
reasoning_tags = [
"think",
"thinking",
"reason",
"reasoning",
"thought",
"Thought",
("think", "/think"),
("thinking", "/thinking"),
("reason", "/reason"),
("reasoning", "/reasoning"),
("thought", "/thought"),
("Thought", "/Thought"),
("|begin_of_thought|", "|end_of_thought|"),
]
code_interpreter_tags = ["code_interpreter"]
code_interpreter_tags = [("code_interpreter", "/code_interpreter")]
solution_tags = [("|begin_of_solution|", "|end_of_solution|")]
try:
for event in events:
@@ -1444,119 +1561,216 @@ async def process_chat_response(
try:
data = json.loads(data)
if "selected_model_id" in data:
model_id = data["selected_model_id"]
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": model_id,
},
)
else:
choices = data.get("choices", [])
if not choices:
continue
data, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type="stream",
form_data=data,
extra_params=extra_params,
)
delta = choices[0].get("delta", {})
delta_tool_calls = delta.get("tool_calls", None)
if delta_tool_calls:
for delta_tool_call in delta_tool_calls:
tool_call_index = delta_tool_call.get("index")
if tool_call_index is not None:
if (
len(response_tool_calls)
<= tool_call_index
):
response_tool_calls.append(
delta_tool_call
)
else:
delta_name = delta_tool_call.get(
"function", {}
).get("name")
delta_arguments = delta_tool_call.get(
"function", {}
).get("arguments")
if delta_name:
response_tool_calls[
tool_call_index
]["function"]["name"] += delta_name
if delta_arguments:
response_tool_calls[
tool_call_index
]["function"][
"arguments"
] += delta_arguments
value = delta.get("content")
if value:
content = f"{content}{value}"
if not content_blocks:
content_blocks.append(
{
"type": "text",
"content": "",
}
)
content_blocks[-1]["content"] = (
content_blocks[-1]["content"] + value
if data:
if "selected_model_id" in data:
model_id = data["selected_model_id"]
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": model_id,
},
)
if DETECT_REASONING:
content, content_blocks, _ = (
tag_content_handler(
"reasoning",
reasoning_tags,
content,
content_blocks,
else:
choices = data.get("choices", [])
if not choices:
error = data.get("error", {})
if error:
await event_emitter(
{
"type": "chat:completion",
"data": {
"error": error,
},
}
)
)
if DETECT_CODE_INTERPRETER:
content, content_blocks, end = (
tag_content_handler(
"code_interpreter",
code_interpreter_tags,
content,
content_blocks,
usage = data.get("usage", {})
if usage:
await event_emitter(
{
"type": "chat:completion",
"data": {
"usage": usage,
},
}
)
)
continue
if end:
break
delta = choices[0].get("delta", {})
delta_tool_calls = delta.get("tool_calls", None)
if delta_tool_calls:
for delta_tool_call in delta_tool_calls:
tool_call_index = delta_tool_call.get(
"index"
)
if tool_call_index is not None:
if (
len(response_tool_calls)
<= tool_call_index
):
response_tool_calls.append(
delta_tool_call
)
else:
delta_name = delta_tool_call.get(
"function", {}
).get("name")
delta_arguments = (
delta_tool_call.get(
"function", {}
).get("arguments")
)
if delta_name:
response_tool_calls[
tool_call_index
]["function"][
"name"
] += delta_name
if delta_arguments:
response_tool_calls[
tool_call_index
]["function"][
"arguments"
] += delta_arguments
value = delta.get("content")
reasoning_content = delta.get(
"reasoning_content"
) or delta.get("reasoning")
if reasoning_content:
if (
not content_blocks
or content_blocks[-1]["type"] != "reasoning"
):
reasoning_block = {
"type": "reasoning",
"start_tag": "think",
"end_tag": "/think",
"attributes": {
"type": "reasoning_content"
},
"content": "",
"started_at": time.time(),
}
content_blocks.append(reasoning_block)
else:
reasoning_block = content_blocks[-1]
reasoning_block["content"] += reasoning_content
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": serialize_content_blocks(
content_blocks
),
},
)
else:
data = {
"content": serialize_content_blocks(
content_blocks
),
)
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
if value:
if (
content_blocks
and content_blocks[-1]["type"]
== "reasoning"
and content_blocks[-1]
.get("attributes", {})
.get("type")
== "reasoning_content"
):
reasoning_block = content_blocks[-1]
reasoning_block["ended_at"] = time.time()
reasoning_block["duration"] = int(
reasoning_block["ended_at"]
- reasoning_block["started_at"]
)
content_blocks.append(
{
"type": "text",
"content": "",
}
)
content = f"{content}{value}"
if not content_blocks:
content_blocks.append(
{
"type": "text",
"content": "",
}
)
content_blocks[-1]["content"] = (
content_blocks[-1]["content"] + value
)
if DETECT_REASONING:
content, content_blocks, _ = (
tag_content_handler(
"reasoning",
reasoning_tags,
content,
content_blocks,
)
)
if DETECT_CODE_INTERPRETER:
content, content_blocks, end = (
tag_content_handler(
"code_interpreter",
code_interpreter_tags,
content,
content_blocks,
)
)
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(
metadata["chat_id"],
metadata["message_id"],
{
"content": serialize_content_blocks(
content_blocks
),
},
)
else:
data = {
"content": serialize_content_blocks(
content_blocks
),
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
except Exception as e:
done = "data: [DONE]" in line
if done:
@@ -1630,6 +1844,15 @@ async def process_chat_response(
)
except Exception as e:
log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(
tool_call.get("function", {}).get("arguments", "{}")
)
except Exception as e:
log.debug(
f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}"
)
tool_result = None
@@ -1638,21 +1861,48 @@ async def process_chat_response(
spec = tool.get("spec", {})
try:
required_params = spec.get("parameters", {}).get(
"required", []
allowed_params = (
spec.get("parameters", {})
.get("properties", {})
.keys()
)
tool_function = tool["callable"]
tool_function_params = {
k: v
for k, v in tool_function_params.items()
if k in required_params
if k in allowed_params
}
tool_result = await tool_function(
**tool_function_params
)
if tool.get("direct", False):
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
"session_id", None
),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(
**tool_function_params
)
except Exception as e:
tool_result = str(e)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(tool_result, indent=2)
results.append(
{
"tool_call_id": tool_call_id,
@@ -1855,8 +2105,6 @@ async def process_chat_response(
}
)
print(content_blocks, serialize_content_blocks(content_blocks))
try:
res = await generate_chat_completion(
request,
@@ -1926,7 +2174,7 @@ async def process_chat_response(
await background_tasks_handler()
except asyncio.CancelledError:
print("Task was cancelled!")
log.warning("Task was cancelled!")
await event_emitter({"type": "task-cancelled"})
if not ENABLE_REALTIME_CHAT_SAVE:
@@ -1947,17 +2195,34 @@ async def process_chat_response(
return {"status": True, "task_id": task_id}
else:
# Fallback to the original response
async def stream_wrapper(original_generator, events):
def wrap_item(item):
return f"data: {item}\n\n"
for event in events:
yield wrap_item(json.dumps(event))
event, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type="stream",
form_data=event,
extra_params=extra_params,
)
if event:
yield wrap_item(json.dumps(event))
async for data in original_generator:
yield data
data, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type="stream",
form_data=data,
extra_params=extra_params,
)
if data:
yield data
return StreamingResponse(
stream_wrapper(response.body_iterator, events),
+19 -1
View File
@@ -2,12 +2,18 @@ import hashlib
import re
import time
import uuid
import logging
from datetime import timedelta
from pathlib import Path
from typing import Callable, Optional
import json
import collections.abc
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def deep_update(d, u):
@@ -412,7 +418,7 @@ def parse_ollama_modelfile(model_text):
elif param_type is bool:
value = value.lower() == "true"
except Exception as e:
print(e)
log.exception(f"Failed to parse parameter {param}: {e}")
continue
data["params"][param] = value
@@ -445,3 +451,15 @@ def parse_ollama_modelfile(model_text):
data["params"]["messages"] = messages
return data
def convert_logit_bias_input_to_json(user_input):
logit_bias_pairs = user_input.split(",")
logit_bias_json = {}
for pair in logit_bias_pairs:
token, bias = pair.split(":")
token = str(token.strip())
bias = int(bias.strip())
bias = 100 if bias > 100 else -100 if bias < -100 else bias
logit_bias_json[token] = bias
return json.dumps(logit_bias_json)
+7 -5
View File
@@ -22,6 +22,7 @@ from open_webui.config import (
)
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.models.users import UserModel
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@@ -29,17 +30,17 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def get_all_base_models(request: Request):
async def get_all_base_models(request: Request, user: UserModel = None):
function_models = []
openai_models = []
ollama_models = []
if request.app.state.config.ENABLE_OPENAI_API:
openai_models = await openai.get_all_models(request)
openai_models = await openai.get_all_models(request, user=user)
openai_models = openai_models["data"]
if request.app.state.config.ENABLE_OLLAMA_API:
ollama_models = await ollama.get_all_models(request)
ollama_models = await ollama.get_all_models(request, user=user)
ollama_models = [
{
"id": model["model"],
@@ -48,6 +49,7 @@ async def get_all_base_models(request: Request):
"created": int(time.time()),
"owned_by": "ollama",
"ollama": model,
"tags": model.get("tags", []),
}
for model in ollama_models["models"]
]
@@ -58,8 +60,8 @@ async def get_all_base_models(request: Request):
return models
async def get_all_models(request):
models = await get_all_base_models(request)
async def get_all_models(request, user: UserModel = None):
models = await get_all_base_models(request, user=user)
# If there are no models, return an empty list
if len(models) == 0:
+10 -16
View File
@@ -94,7 +94,7 @@ class OAuthManager:
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = None
oauth_roles = []
# Default/fallback role if no matching roles are found
role = auth_manager_config.DEFAULT_USER_ROLE
@@ -104,7 +104,7 @@ 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 None
oauth_roles = claim_data if isinstance(claim_data, list) else []
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
@@ -140,13 +140,14 @@ class OAuthManager:
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
user_oauth_groups = []
# Nested claim search for groups claim
if oauth_claim:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
user_oauth_groups = claim_data if isinstance(claim_data, list) else None
user_oauth_groups = claim_data if isinstance(claim_data, list) else []
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_groups()
@@ -160,7 +161,7 @@ class OAuthManager:
# Remove groups that user is no longer a part of
for group_model in user_current_groups:
if group_model.name not in user_oauth_groups:
if user_oauth_groups and group_model.name not in user_oauth_groups:
# Remove group from user
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
@@ -186,8 +187,10 @@ class OAuthManager:
# Add user to new groups
for group_model in all_available_groups:
if group_model.name in user_oauth_groups and not any(
gm.name == group_model.name for gm in user_current_groups
if (
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)
):
# Add user to group
log.debug(
@@ -234,7 +237,7 @@ class OAuthManager:
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 "email" not in user_data:
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}")
@@ -315,15 +318,6 @@ class OAuthManager:
if not user:
user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
403,
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
+32 -1
View File
@@ -62,6 +62,8 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
"reasoning_effort": str,
"seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"logit_bias": lambda x: x,
"response_format": dict,
}
return apply_model_params_to_body(params, form_data, mappings)
@@ -109,6 +111,15 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"num_thread": int,
}
# Extract keep_alive from options if it exists
if "options" in form_data and "keep_alive" in form_data["options"]:
form_data["keep_alive"] = form_data["options"]["keep_alive"]
del form_data["options"]["keep_alive"]
if "options" in form_data and "format" in form_data["options"]:
form_data["format"] = form_data["options"]["format"]
del form_data["options"]["format"]
return apply_model_params_to_body(params, form_data, mappings)
@@ -124,7 +135,7 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
tool_call_id = message.get("tool_call_id", None)
# Check if the content is a string (just a simple message)
if isinstance(content, str):
if isinstance(content, str) and not tool_calls:
# If the content is a string, it's pure text
new_message["content"] = content
@@ -230,7 +241,27 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
"system"
] # To prevent Ollama warning of invalid option provided
# Extract keep_alive from options if it exists
if "keep_alive" in ollama_options:
ollama_payload["keep_alive"] = ollama_options["keep_alive"]
del ollama_options["keep_alive"]
# If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
if "stop" in openai_payload:
ollama_options = ollama_payload.get("options", {})
ollama_options["stop"] = openai_payload.get("stop")
ollama_payload["options"] = ollama_options
if "metadata" in openai_payload:
ollama_payload["metadata"] = openai_payload["metadata"]
if "response_format" in openai_payload:
response_format = openai_payload["response_format"]
format_type = response_format.get("type", None)
schema = response_format.get(format_type, None)
if schema:
format = schema.get("schema", None)
ollama_payload["format"] = format
return ollama_payload
+1 -1
View File
@@ -110,7 +110,7 @@ class PDFGenerator:
# When running using `pip install -e .` the static directory is in the site packages.
# This path only works if `open-webui serve` is run from the root of this project.
if not FONTS_DIR.exists():
FONTS_DIR = Path("./backend/static/fonts")
FONTS_DIR = Path(".") / "backend" / "static" / "fonts"
pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf")
pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf")
+11 -7
View File
@@ -7,7 +7,7 @@ import types
import tempfile
import logging
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS
from open_webui.models.functions import Functions
from open_webui.models.tools import Tools
@@ -45,7 +45,7 @@ def extract_frontmatter(content):
frontmatter[key.strip()] = value.strip()
except Exception as e:
print(f"An error occurred: {e}")
log.exception(f"Failed to extract frontmatter: {e}")
return {}
return frontmatter
@@ -165,15 +165,19 @@ def load_function_module_by_id(function_id, content=None):
os.unlink(temp_file.name)
def install_frontmatter_requirements(requirements):
def install_frontmatter_requirements(requirements: str):
if requirements:
try:
req_list = [req.strip() for req in requirements.split(",")]
for req in req_list:
log.info(f"Installing requirement: {req}")
subprocess.check_call([sys.executable, "-m", "pip", "install", req])
log.info(f"Installing requirements: {' '.join(req_list)}")
subprocess.check_call(
[sys.executable, "-m", "pip", "install"]
+ PIP_OPTIONS
+ req_list
+ PIP_PACKAGE_INDEX_OPTIONS
)
except Exception as e:
log.error(f"Error installing package: {req}")
log.error(f"Error installing packages: {' '.join(req_list)}")
raise e
else:
+109
View File
@@ -0,0 +1,109 @@
import socketio
import redis
from redis import asyncio as aioredis
from urllib.parse import urlparse
def parse_redis_sentinel_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis":
raise ValueError("Invalid Redis URL scheme. Must be 'redis'.")
return {
"username": parsed_url.username or None,
"password": parsed_url.password or None,
"service": parsed_url.hostname or "mymaster",
"port": parsed_url.port or 6379,
"db": int(parsed_url.path.lstrip("/") or 0),
}
def get_redis_connection(redis_url, redis_sentinels, decode_responses=True):
if redis_sentinels:
redis_config = parse_redis_sentinel_url(redis_url)
sentinel = redis.sentinel.Sentinel(
redis_sentinels,
port=redis_config["port"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
decode_responses=decode_responses,
)
# Get a master connection from Sentinel
return sentinel.master_for(redis_config["service"])
else:
# Standard Redis connection
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
if sentinel_hosts_env:
sentinel_hosts = sentinel_hosts_env.split(",")
sentinel_port = int(sentinel_port_env)
return [(host, sentinel_port) for host in sentinel_hosts]
return []
class AsyncRedisSentinelManager(socketio.AsyncRedisManager):
def __init__(
self,
sentinel_hosts,
sentinel_port=26379,
redis_port=6379,
service="mymaster",
db=0,
username=None,
password=None,
channel="socketio",
write_only=False,
logger=None,
redis_options=None,
):
"""
Initialize the Redis Sentinel Manager.
This implementation mostly replicates the __init__ of AsyncRedisManager and
overrides _redis_connect() with a version that uses Redis Sentinel
:param sentinel_hosts: List of Sentinel hosts
:param sentinel_port: Sentinel Port
:param redis_port: Redis Port (currently unsupported by aioredis!)
:param service: Master service name in Sentinel
:param db: Redis database to use
:param username: Redis username (if any) (currently unsupported by aioredis!)
:param password: Redis password (if any)
:param channel: The channel name on which the server sends and receives
notifications. Must be the same in all the servers.
:param write_only: If set to ``True``, only initialize to emit events. The
default of ``False`` initializes the class for emitting
and receiving.
:param redis_options: additional keyword arguments to be passed to
``aioredis.from_url()``.
"""
self._sentinels = [(host, sentinel_port) for host in sentinel_hosts]
self._redis_port = redis_port
self._service = service
self._db = db
self._username = username
self._password = password
self._channel = channel
self.redis_options = redis_options or {}
# connect and call grandparent constructor
self._redis_connect()
super(socketio.AsyncRedisManager, self).__init__(
channel=channel, write_only=write_only, logger=logger
)
def _redis_connect(self):
"""Establish connections to Redis through Sentinel."""
sentinel = aioredis.sentinel.Sentinel(
self._sentinels,
port=self._redis_port,
db=self._db,
password=self._password,
**self.redis_options,
)
self.redis = sentinel.master_for(self._service)
self.pubsub = self.redis.pubsub(ignore_subscribe_messages=True)
+2 -2
View File
@@ -104,7 +104,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
data = json.loads(data)
model = data.get("model", "ollama")
message_content = data.get("message", {}).get("content", "")
message_content = data.get("message", {}).get("content", None)
tool_calls = data.get("message", {}).get("tool_calls", None)
openai_tool_calls = None
@@ -118,7 +118,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
usage = convert_ollama_usage_to_openai(data)
data = openai_chat_chunk_message_template(
model, message_content if not done else None, openai_tool_calls, usage
model, message_content, openai_tool_calls, usage
)
line = f"data: {json.dumps(data)}\n\n"
+1 -1
View File
@@ -104,7 +104,7 @@ def replace_prompt_variable(template: str, prompt: str) -> str:
def replace_messages_variable(
template: str, messages: Optional[list[str]] = None
template: str, messages: Optional[list[dict]] = None
) -> str:
def replacement_function(match):
full_match = match.group(0)
@@ -0,0 +1,26 @@
from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes
# Span Tags
SPAN_DB_TYPE = "mysql"
SPAN_REDIS_TYPE = "redis"
SPAN_DURATION = "duration"
SPAN_SQL_STR = "sql"
SPAN_SQL_EXPLAIN = "explain"
SPAN_ERROR_TYPE = "error"
class SpanAttributes(_SpanAttributes):
"""
Span Attributes
"""
DB_INSTANCE = "db.instance"
DB_TYPE = "db.type"
DB_IP = "db.ip"
DB_PORT = "db.port"
ERROR_KIND = "error.kind"
ERROR_OBJECT = "error.object"
ERROR_MESSAGE = "error.message"
RESULT_CODE = "result.code"
RESULT_MESSAGE = "result.message"
RESULT_ERRORS = "result.errors"
@@ -0,0 +1,31 @@
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,202 @@
import logging
import traceback
from typing import Collection, Union
from aiohttp import (
TraceRequestStartParams,
TraceRequestEndParams,
TraceRequestExceptionParams,
)
from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi
from fastapi import FastAPI
from opentelemetry.instrumentation.httpx import (
HTTPXClientInstrumentor,
RequestInfo,
ResponseInfo,
)
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
from opentelemetry.trace import Span, StatusCode
from redis import Redis
from requests import PreparedRequest, Response
from sqlalchemy import Engine
from fastapi import status
from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes
from open_webui.env import SRC_LOG_LEVELS
logger = logging.getLogger(__name__)
logger.setLevel(SRC_LOG_LEVELS["MAIN"])
def requests_hook(span: Span, request: PreparedRequest):
"""
Http Request Hook
"""
span.update_name(f"{request.method} {request.url}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: request.url,
SpanAttributes.HTTP_METHOD: request.method,
}
)
def response_hook(span: Span, request: PreparedRequest, response: Response):
"""
HTTP Response Hook
"""
span.set_attributes(
attributes={
SpanAttributes.HTTP_STATUS_CODE: response.status_code,
}
)
span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK)
def redis_request_hook(span: Span, instance: Redis, args, kwargs):
"""
Redis Request Hook
"""
try:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
span.set_attributes(
{
SpanAttributes.DB_INSTANCE: f"{host}/{db}",
SpanAttributes.DB_NAME: f"{host}/{db}",
SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE,
SpanAttributes.DB_PORT: port,
SpanAttributes.DB_IP: host,
SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]),
SpanAttributes.DB_OPERATION: str(args[0]),
}
)
except Exception: # pylint: disable=W0718
logger.error(traceback.format_exc())
def httpx_request_hook(span: Span, request: RequestInfo):
"""
HTTPX Request Hook
"""
span.update_name(f"{request.method.decode()} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method.decode(),
}
)
def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo):
"""
HTTPX Response Hook
"""
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
span.set_status(
StatusCode.ERROR
if response.status_code >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
async def httpx_async_request_hook(span: Span, request: RequestInfo):
"""
Async Request Hook
"""
httpx_request_hook(span, request)
async def httpx_async_response_hook(
span: Span, request: RequestInfo, response: ResponseInfo
):
"""
Async Response Hook
"""
httpx_response_hook(span, request, response)
def aiohttp_request_hook(span: Span, request: TraceRequestStartParams):
"""
Aiohttp Request Hook
"""
span.update_name(f"{request.method} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method,
}
)
def aiohttp_response_hook(
span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]
):
"""
Aiohttp Response Hook
"""
if isinstance(response, TraceRequestEndParams):
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status)
span.set_status(
StatusCode.ERROR
if response.response.status >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
elif isinstance(response, TraceRequestExceptionParams):
span.set_status(StatusCode.ERROR)
span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception))
class Instrumentor(BaseInstrumentor):
"""
Instrument OT
"""
def __init__(self, app: FastAPI, db_engine: Engine):
self.app = app
self.db_engine = db_engine
def instrumentation_dependencies(self) -> Collection[str]:
return []
def _instrument(self, **kwargs):
instrument_fastapi(app=self.app)
SQLAlchemyInstrumentor().instrument(engine=self.db_engine)
RedisInstrumentor().instrument(request_hook=redis_request_hook)
RequestsInstrumentor().instrument(
request_hook=requests_hook, response_hook=response_hook
)
LoggingInstrumentor().instrument()
HTTPXClientInstrumentor().instrument(
request_hook=httpx_request_hook,
response_hook=httpx_response_hook,
async_request_hook=httpx_async_request_hook,
async_response_hook=httpx_async_response_hook,
)
AioHttpClientInstrumentor().instrument(
request_hook=aiohttp_request_hook,
response_hook=aiohttp_response_hook,
)
def _uninstrument(self, **kwargs):
if getattr(self, "instrumentors", None) is None:
return
for instrumentor in self.instrumentors:
instrumentor.uninstrument()
@@ -0,0 +1,23 @@
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from sqlalchemy import Engine
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
def setup(app: FastAPI, db_engine: Engine):
# set up trace
trace.set_tracer_provider(
TracerProvider(
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
)
)
# otlp export
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument()
+6 -2
View File
@@ -1,6 +1,9 @@
import inspect
import logging
import re
import inspect
import uuid
from typing import Any, Awaitable, Callable, get_type_hints
from functools import update_wrapper, partial
@@ -88,10 +91,11 @@ def get_tools(
# TODO: This needs to be a pydantic model
tool_dict = {
"toolkit_id": tool_id,
"callable": callable,
"spec": spec,
"callable": callable,
"toolkit_id": tool_id,
"pydantic_model": function_to_pydantic_model(callable),
# Misc info
"file_handler": hasattr(module, "file_handler") and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
}
+30 -10
View File
@@ -1,10 +1,10 @@
fastapi==0.115.7
uvicorn[standard]==0.30.6
uvicorn[standard]==0.34.0
pydantic==2.10.6
python-multipart==0.0.18
python-socketio==5.11.3
python-jose==3.3.0
python-jose==3.4.0
passlib[bcrypt]==1.7.4
requests==2.32.3
@@ -13,14 +13,14 @@ async-timeout
aiocache
aiofiles
sqlalchemy==2.0.32
sqlalchemy==2.0.38
alembic==1.14.0
peewee==3.17.8
peewee==3.17.9
peewee-migrate==1.12.2
psycopg2-binary==2.9.9
pgvector==0.3.5
PyMySQL==1.1.1
bcrypt==4.2.0
bcrypt==4.3.0
pymongo
redis
@@ -31,21 +31,26 @@ APScheduler==3.10.4
RestrictedPython==8.0
loguru==0.7.2
asgiref==3.8.1
# AI libraries
openai
anthropic
google-generativeai==0.7.2
google-generativeai==0.8.4
tiktoken
langchain==0.3.7
langchain-community==0.3.7
langchain==0.3.19
langchain-community==0.3.18
fake-useragent==1.5.1
fake-useragent==2.1.0
chromadb==0.6.2
pymilvus==2.5.0
qdrant-client~=1.12.0
opensearch-py==2.8.0
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==8.17.1
transformers
sentence-transformers==3.3.1
@@ -71,7 +76,9 @@ validators==0.34.0
psutil
sentencepiece
soundfile==0.13.1
azure-ai-documentintelligence==1.0.0
pillow==11.1.0
opencv-python-headless==4.11.0.86
rapidocr-onnxruntime==1.3.24
rank-bm25==0.2.2
@@ -81,7 +88,7 @@ faster-whisper==1.1.1
PyJWT[crypto]==2.10.1
authlib==1.4.1
black==24.8.0
black==25.1.0
langfuse==2.44.0
youtube-transcript-api==0.6.3
pytube==15.0.0
@@ -112,3 +119,16 @@ ldap3==2.9.1
## Firecrawl
firecrawl-py==1.12.0
## Trace
opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-exporter-otlp==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-instrumentation-sqlalchemy==0.51b0
opentelemetry-instrumentation-redis==0.51b0
opentelemetry-instrumentation-requests==0.51b0
opentelemetry-instrumentation-logging==0.51b0
opentelemetry-instrumentation-httpx==0.51b0
opentelemetry-instrumentation-aiohttp-client==0.51b0
+2 -1
View File
@@ -41,4 +41,5 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
:: Execute uvicorn
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*'
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ws auto
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto
+586 -212
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.5.15",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -51,6 +51,7 @@
},
"type": "module",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language-data": "^6.5.1",
@@ -79,11 +80,14 @@
"file-saver": "^2.0.5",
"fuse.js": "^7.0.0",
"highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"katex": "^0.16.21",
"kokoro-js": "^1.1.1",
"marked": "^9.1.0",
@@ -100,7 +104,7 @@
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.2",
"pyodide": "^0.27.3",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",
"svelte-sonner": "^0.3.19",
+15 -9
View File
@@ -7,12 +7,12 @@ authors = [
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.115.7",
"uvicorn[standard]==0.30.6",
"uvicorn[standard]==0.34.0",
"pydantic==2.10.6",
"python-multipart==0.0.18",
"python-socketio==5.11.3",
"python-jose==3.3.0",
"python-jose==3.4.0",
"passlib[bcrypt]==1.7.4",
"requests==2.32.3",
@@ -21,14 +21,14 @@ dependencies = [
"aiocache",
"aiofiles",
"sqlalchemy==2.0.32",
"sqlalchemy==2.0.38",
"alembic==1.14.0",
"peewee==3.17.8",
"peewee==3.17.9",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.3.5",
"PyMySQL==1.1.1",
"bcrypt==4.2.0",
"bcrypt==4.3.0",
"pymongo",
"redis",
@@ -40,20 +40,24 @@ dependencies = [
"RestrictedPython==8.0",
"loguru==0.7.2",
"asgiref==3.8.1",
"openai",
"anthropic",
"google-generativeai==0.7.2",
"tiktoken",
"langchain==0.3.7",
"langchain-community==0.3.7",
"langchain==0.3.19",
"langchain-community==0.3.18",
"fake-useragent==1.5.1",
"fake-useragent==2.1.0",
"chromadb==0.6.2",
"pymilvus==2.5.0",
"qdrant-client~=1.12.0",
"opensearch-py==2.8.0",
"playwright==1.49.1",
"elasticsearch==8.17.1",
"transformers",
"sentence-transformers==3.3.1",
@@ -78,7 +82,9 @@ dependencies = [
"psutil",
"sentencepiece",
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.0",
"pillow==11.1.0",
"opencv-python-headless==4.11.0.86",
"rapidocr-onnxruntime==1.3.24",
"rank-bm25==0.2.2",
@@ -88,7 +94,7 @@ dependencies = [
"PyJWT[crypto]==2.10.1",
"authlib==1.4.1",
"black==24.8.0",
"black==25.1.0",
"langfuse==2.44.0",
"youtube-transcript-api==0.6.3",
"pytube==15.0.0",
+1 -1
View File
@@ -106,7 +106,7 @@ li p {
}
::-webkit-scrollbar {
height: 0.4rem;
height: 0.8rem;
width: 0.4rem;
}
+29 -9
View File
@@ -2,12 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/png" href="/static/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="shortcut icon" href="/static/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
@@ -74,6 +76,28 @@
}
}
});
function setSplashImage() {
const logo = document.getElementById('logo');
const isDarkMode = document.documentElement.classList.contains('dark');
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = '/static/splash-dark.png';
darkImage.onload = () => {
logo.src = '/static/splash-dark.png';
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
};
darkImage.onerror = () => {
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
};
}
}
// Runs after classes are assigned
window.onload = setSplashImage;
})();
</script>
@@ -176,10 +200,6 @@
background: #000;
}
html.dark #splash-screen img {
filter: invert(1);
}
html.her #splash-screen {
background: #983724;
}
+183
View File
@@ -1,6 +1,9 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { convertOpenApiToToolPayload } from '$lib/utils';
import { getOpenAIModelsDirect } from './openai';
import { toast } from 'svelte-sonner';
export const getModels = async (
token: string = '',
connections: object | null = null,
@@ -114,6 +117,13 @@ export const getModels = async (
}
}
const tags = apiConfig.tags;
if (tags) {
for (const model of models) {
model.tags = tags;
}
}
localModels = localModels.concat(models);
}
}
@@ -249,6 +259,179 @@ export const stopTask = async (token: string, id: string) => {
return res;
};
export const getToolServerData = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${url}/openapi.json`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
const data = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
};
export const getToolServersData = async (i18n, servers: object[]) => {
return (
await Promise.all(
servers
.filter((server) => server?.config?.enable)
.map(async (server) => {
const data = await getToolServerData(server?.key, server?.url).catch((err) => {
toast.error(
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: server?.url
})
);
return null;
});
if (data) {
const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,
info: info,
specs: specs
};
}
})
)
).filter((server) => server);
};
export const executeToolServer = async (
token: string,
url: string,
name: string,
params: Record<string, any>,
serverData: { openapi: any; info: any; specs: any }
) => {
let error = null;
try {
// Find the matching operationId in the OpenAPI spec
const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) =>
Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name)
);
if (!matchingRoute) {
throw new Error(`No matching route found for operationId: ${name}`);
}
const [routePath, methods] = matchingRoute;
const methodEntry = Object.entries(methods as any).find(
([_, operation]: any) => operation.operationId === name
);
if (!methodEntry) {
throw new Error(`No matching method found for operationId: ${name}`);
}
const [httpMethod, operation]: [string, any] = methodEntry;
// Split parameters by type
const pathParams: Record<string, any> = {};
const queryParams: Record<string, any> = {};
let bodyParams: any = {};
if (operation.parameters) {
operation.parameters.forEach((param: any) => {
const paramName = param.name;
const paramIn = param.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
} else if (paramIn === 'query') {
queryParams[paramName] = params[paramName];
}
}
});
}
let finalUrl = `${url}${routePath}`;
// Replace path parameters (`{param}`)
Object.entries(pathParams).forEach(([key, value]) => {
finalUrl = finalUrl.replace(new RegExp(`{${key}}`, 'g'), encodeURIComponent(value));
});
// Append query parameters to URL if any
if (Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(
Object.entries(queryParams).map(([k, v]) => [k, String(v)])
).toString();
finalUrl += `?${queryString}`;
}
// Handle requestBody composite
if (operation.requestBody && operation.requestBody.content) {
const contentType = Object.keys(operation.requestBody.content)[0];
if (params !== undefined) {
bodyParams = params;
} else {
// Optional: Fallback or explicit error if body is expected but not provided
throw new Error(`Request body expected for operation '${name}' but none found.`);
}
}
// Prepare headers and request options
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
};
let requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
headers
};
if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) {
requestOptions.body = JSON.stringify(bodyParams);
}
const res = await fetch(finalUrl, requestOptions);
if (!res.ok) {
const resText = await res.text();
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
}
return await res.json();
} catch (err: any) {
error = err.message;
console.error('API Request Error:', error);
return { error };
}
};
export const getTaskConfig = async (token: string = '') => {
let error = null;
+7
View File
@@ -32,9 +32,15 @@ type ChunkConfigForm = {
chunk_overlap: number;
};
type DocumentIntelligenceConfigForm = {
key: string;
endpoint: string;
};
type ContentExtractConfigForm = {
engine: string;
tika_server_url: string | null;
document_intelligence_config: DocumentIntelligenceConfigForm | null;
};
type YoutubeConfigForm = {
@@ -46,6 +52,7 @@ type YoutubeConfigForm = {
type RAGConfigForm = {
pdf_extract_images?: boolean;
enable_google_drive_integration?: boolean;
enable_onedrive_integration?: boolean;
chunk?: ChunkConfigForm;
content_extraction?: ContentExtractConfigForm;
web_loader_ssl_verification?: boolean;
+39 -8
View File
@@ -14,6 +14,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -31,6 +32,7 @@
let prefixId = '';
let enable = true;
let tags = [];
let modelId = '';
let modelIds = [];
@@ -77,17 +79,21 @@
const submitHandler = async () => {
loading = true;
if (!ollama && (!url || !key)) {
if (!ollama && !url) {
loading = false;
toast.error('URL and Key are required');
toast.error('URL is required');
return;
}
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
key,
config: {
enable: enable,
tags: tags,
prefix_id: prefixId,
model_ids: modelIds
}
@@ -101,6 +107,7 @@
url = '';
key = '';
prefixId = '';
tags = [];
modelIds = [];
};
@@ -110,6 +117,7 @@
key = connection.key;
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
modelIds = connection.config?.model_ids ?? [];
}
@@ -179,7 +187,7 @@
</div>
</div>
<Tooltip content="Verify Connection" className="self-end -mb-1">
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
@@ -218,7 +226,7 @@
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={!ollama}
required={false}
/>
</div>
</div>
@@ -244,6 +252,29 @@
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-1.5 text-xs text-gray-500">{$i18n.t('Tags')}</div>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full">
@@ -274,12 +305,12 @@
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{URL}}/api/tags" endpoint', {
URL: url
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else}
{$i18n.t('Leave empty to include all models from "{{URL}}/models" endpoint', {
URL: url
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>

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