Skip to content

Commit f31768e

Browse files
authored
Merge pull request open-webui#23318 from open-webui/dev
0.9.0
2 parents 9bd8425 + 493f238 commit f31768e

304 files changed

Lines changed: 32300 additions & 9655 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/pull_request_template.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010

1111
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
1212

13+
<!--
14+
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
15+
16+
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
17+
-->
18+
1319
**Before submitting, make sure you've checked the following:**
1420

1521
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**

CHANGELOG.md

Lines changed: 225 additions & 0 deletions
Large diffs are not rendered by default.

LICENSE

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Open WebUI License
2+
13
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
24
All rights reserved.
35

@@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met:
1517
contributors may be used to endorse or promote products derived from
1618
this software without specific prior written permission.
1719

18-
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
19-
20-
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
21-
22-
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
20+
4. Notwithstanding any other provision of this License, and as a material
21+
condition of the rights granted herein, licensees are strictly prohibited
22+
from altering, removing, obscuring, or replacing any "Open WebUI"
23+
branding, including but not limited to the name, logo, or any visual,
24+
textual, or symbolic identifiers that distinguish the software and its
25+
interfaces, in any deployment or distribution, except in the following
26+
circumstances: (i) deployments or distributions where the total number
27+
of end users (defined as individual natural persons with direct access
28+
to the application) does not exceed fifty (50) within any rolling
29+
thirty (30) day period; (ii) the licensee has obtained specific prior
30+
written permission from the copyright holder; or (iii) where the
31+
licensee has obtained a duly executed enterprise license expressly
32+
permitting such modification. For all other cases, any removal or
33+
alteration of the "Open WebUI" branding shall constitute a material
34+
breach of license.
35+
36+
Materials governed by prior licenses retain those original license
37+
terms, as specified in LICENSE_HISTORY.
38+
39+
By contributing to this project, you agree to the project's Contributor
40+
License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT).
2341

2442
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
2543
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,6 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
172172

173173
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
174174

175-
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
176-
177175
### Troubleshooting
178176

179177
Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).

backend/open_webui/config.py

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
import logging
34
import os
@@ -35,7 +36,7 @@
3536
WEBUI_NAME,
3637
log,
3738
)
38-
from open_webui.internal.db import Base, get_db
39+
from open_webui.internal.db import Base, get_db, get_async_db
3940
from open_webui.utils.redis import get_redis_connection
4041

4142

@@ -90,6 +91,7 @@ def load_json_config():
9091

9192

9293
def save_to_db(data):
94+
"""Sync save — used ONLY at startup/import time."""
9395
with get_db() as db:
9496
existing_config = db.query(Config).first()
9597
if not existing_config:
@@ -102,12 +104,39 @@ def save_to_db(data):
102104
db.commit()
103105

104106

107+
async def async_save_to_db(data):
108+
"""Async save — used for ALL runtime config persistence."""
109+
from sqlalchemy import select
110+
111+
async with get_async_db() as db:
112+
result = await db.execute(select(Config).limit(1))
113+
existing_config = result.scalars().first()
114+
if not existing_config:
115+
new_config = Config(data=data, version=0)
116+
db.add(new_config)
117+
else:
118+
existing_config.data = data
119+
existing_config.updated_at = datetime.now()
120+
db.add(existing_config)
121+
await db.commit()
122+
123+
105124
def reset_config():
125+
"""Sync reset — used ONLY at startup."""
106126
with get_db() as db:
107127
db.query(Config).delete()
108128
db.commit()
109129

110130

131+
async def async_reset_config():
132+
"""Async reset — used at runtime."""
133+
from sqlalchemy import delete as sa_delete
134+
135+
async with get_async_db() as db:
136+
await db.execute(sa_delete(Config))
137+
await db.commit()
138+
139+
111140
# When initializing, check if config.json exists and migrate it to the database
112141
if os.path.exists(f'{DATA_DIR}/config.json'):
113142
data = load_json_config()
@@ -144,6 +173,7 @@ def get_config_value(config_path: str):
144173

145174

146175
def save_config(config):
176+
"""Sync save — used ONLY at startup/import time."""
147177
global CONFIG_DATA
148178
global PERSISTENT_CONFIG_REGISTRY
149179
try:
@@ -159,6 +189,23 @@ def save_config(config):
159189
return True
160190

161191

192+
async def async_save_config(config):
193+
"""Async save — used for ALL runtime config persistence."""
194+
global CONFIG_DATA
195+
global PERSISTENT_CONFIG_REGISTRY
196+
try:
197+
await async_save_to_db(config)
198+
CONFIG_DATA = config
199+
200+
# Trigger updates on all registered PersistentConfig entries
201+
for config_item in PERSISTENT_CONFIG_REGISTRY:
202+
config_item.update()
203+
except Exception as e:
204+
log.exception(e)
205+
return False
206+
return True
207+
208+
162209
T = TypeVar('T')
163210

164211
ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true'
@@ -202,6 +249,7 @@ def update(self):
202249
log.info(f'Updated {self.env_name} to new value {self.value}')
203250

204251
def save(self):
252+
"""Sync save — used ONLY at startup/import time."""
205253
log.info(f"Saving '{self.env_name}' to the database")
206254
path_parts = self.config_path.split('.')
207255
sub_config = CONFIG_DATA
@@ -213,6 +261,19 @@ def save(self):
213261
save_to_db(CONFIG_DATA)
214262
self.config_value = self.value
215263

264+
async def async_save(self):
265+
"""Async save — used for ALL runtime config persistence."""
266+
log.info(f"Saving '{self.env_name}' to the database")
267+
path_parts = self.config_path.split('.')
268+
sub_config = CONFIG_DATA
269+
for key in path_parts[:-1]:
270+
if key not in sub_config:
271+
sub_config[key] = {}
272+
sub_config = sub_config[key]
273+
sub_config[path_parts[-1]] = self.value
274+
await async_save_to_db(CONFIG_DATA)
275+
self.config_value = self.value
276+
216277

217278
class AppConfig:
218279
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
@@ -246,12 +307,27 @@ def __setattr__(self, key, value):
246307
self._state[key] = value
247308
else:
248309
self._state[key].value = value
249-
self._state[key].save()
310+
311+
# At runtime (inside the event loop) persist via the async engine
312+
# to avoid blocking the loop and contending with the async DB pool.
313+
# At startup/import time, fall back to sync.
314+
try:
315+
loop = asyncio.get_running_loop()
316+
loop.create_task(self._async_persist(key))
317+
except RuntimeError:
318+
self._state[key].save()
250319

251320
if self._redis and ENABLE_PERSISTENT_CONFIG:
252321
redis_key = f'{self._redis_key_prefix}:config:{key}'
253322
self._redis.set(redis_key, json.dumps(self._state[key].value))
254323

324+
async def _async_persist(self, key):
325+
"""Persist a single config key via the async engine."""
326+
try:
327+
await self._state[key].async_save()
328+
except Exception as e:
329+
log.error(f'Failed to async-persist config key {key}: {e}')
330+
255331
def __getattr__(self, key):
256332
if key not in self._state:
257333
raise AttributeError(f"Config key '{key}' not found")
@@ -687,7 +763,6 @@ def google_oauth_register(oauth: OAuth):
687763
return client
688764

689765
OAUTH_PROVIDERS['google'] = {
690-
'redirect_uri': GOOGLE_REDIRECT_URI.value,
691766
'register': google_oauth_register,
692767
}
693768

@@ -708,7 +783,6 @@ def microsoft_oauth_register(oauth: OAuth):
708783
return client
709784

710785
OAUTH_PROVIDERS['microsoft'] = {
711-
'redirect_uri': MICROSOFT_REDIRECT_URI.value,
712786
'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value,
713787
'register': microsoft_oauth_register,
714788
}
@@ -733,7 +807,6 @@ def github_oauth_register(oauth: OAuth):
733807
return client
734808

735809
OAUTH_PROVIDERS['github'] = {
736-
'redirect_uri': GITHUB_CLIENT_REDIRECT_URI.value,
737810
'register': github_oauth_register,
738811
'sub_claim': 'id',
739812
}
@@ -775,7 +848,6 @@ def oidc_oauth_register(oauth: OAuth):
775848

776849
OAUTH_PROVIDERS['oidc'] = {
777850
'name': OAUTH_PROVIDER_NAME.value,
778-
'redirect_uri': OPENID_REDIRECT_URI.value,
779851
'register': oidc_oauth_register,
780852
}
781853

@@ -919,6 +991,7 @@ def feishu_oauth_register(oauth: OAuth):
919991
####################################
920992

921993
STORAGE_PROVIDER = os.environ.get('STORAGE_PROVIDER', 'local') # defaults to local, s3
994+
STORAGE_LOCAL_CACHE = os.environ.get('STORAGE_LOCAL_CACHE', 'true').lower() == 'true'
922995

923996
S3_ACCESS_KEY_ID = os.environ.get('S3_ACCESS_KEY_ID', None)
924997
S3_SECRET_ACCESS_KEY = os.environ.get('S3_SECRET_ACCESS_KEY', None)
@@ -1152,10 +1225,16 @@ def reachable(host: str, port: int) -> bool:
11521225

11531226
ENABLE_LOGIN_FORM = PersistentConfig(
11541227
'ENABLE_LOGIN_FORM',
1155-
'ui.ENABLE_LOGIN_FORM',
1228+
'ui.enable_login_form',
11561229
os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true',
11571230
)
11581231

1232+
ENABLE_PASSWORD_CHANGE_FORM = PersistentConfig(
1233+
'ENABLE_PASSWORD_CHANGE_FORM',
1234+
'ui.enable_password_change_form',
1235+
os.environ.get('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true',
1236+
)
1237+
11591238
ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true'
11601239

11611240
DEFAULT_LOCALE = PersistentConfig(
@@ -1226,10 +1305,16 @@ def reachable(host: str, port: int) -> bool:
12261305
{},
12271306
)
12281307

1308+
try:
1309+
default_model_params = json.loads(os.environ.get('DEFAULT_MODEL_PARAMS', '{}'))
1310+
except Exception as e:
1311+
log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}')
1312+
default_model_params = {}
1313+
12291314
DEFAULT_MODEL_PARAMS = PersistentConfig(
12301315
'DEFAULT_MODEL_PARAMS',
12311316
'models.default_params',
1232-
{},
1317+
default_model_params,
12331318
)
12341319

12351320
DEFAULT_USER_ROLE = PersistentConfig(
@@ -1435,6 +1520,12 @@ def reachable(host: str, port: int) -> bool:
14351520

14361521
USER_PERMISSIONS_FEATURES_MEMORIES = os.environ.get('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true'
14371522

1523+
USER_PERMISSIONS_FEATURES_AUTOMATIONS = (
1524+
os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true'
1525+
)
1526+
1527+
USER_PERMISSIONS_FEATURES_CALENDAR = os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true'
1528+
14381529

14391530
USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true'
14401531

@@ -1504,6 +1595,8 @@ def reachable(host: str, port: int) -> bool:
15041595
'image_generation': USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
15051596
'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
15061597
'memories': USER_PERMISSIONS_FEATURES_MEMORIES,
1598+
'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS,
1599+
'calendar': USER_PERMISSIONS_FEATURES_CALENDAR,
15071600
},
15081601
'settings': {
15091602
'interface': USER_PERMISSIONS_SETTINGS_INTERFACE,
@@ -1534,6 +1627,30 @@ def reachable(host: str, port: int) -> bool:
15341627
os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true',
15351628
)
15361629

1630+
ENABLE_CALENDAR = PersistentConfig(
1631+
'ENABLE_CALENDAR',
1632+
'calendar.enable',
1633+
os.environ.get('ENABLE_CALENDAR', 'True').lower() == 'true',
1634+
)
1635+
1636+
ENABLE_AUTOMATIONS = PersistentConfig(
1637+
'ENABLE_AUTOMATIONS',
1638+
'automations.enable',
1639+
os.environ.get('ENABLE_AUTOMATIONS', 'True').lower() == 'true',
1640+
)
1641+
1642+
AUTOMATION_MAX_COUNT = PersistentConfig(
1643+
'AUTOMATION_MAX_COUNT',
1644+
'automations.max_count',
1645+
os.environ.get('AUTOMATION_MAX_COUNT', ''),
1646+
)
1647+
1648+
AUTOMATION_MIN_INTERVAL = PersistentConfig(
1649+
'AUTOMATION_MIN_INTERVAL',
1650+
'automations.min_interval',
1651+
os.environ.get('AUTOMATION_MIN_INTERVAL', ''),
1652+
)
1653+
15371654
ENABLE_NOTES = PersistentConfig(
15381655
'ENABLE_NOTES',
15391656
'notes.enable',
@@ -2863,6 +2980,12 @@ class BannerModel(BaseModel):
28632980
os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true'
28642981
)
28652982

2983+
RAG_RERANKING_BATCH_SIZE = PersistentConfig(
2984+
'RAG_RERANKING_BATCH_SIZE',
2985+
'rag.reranking_batch_size',
2986+
int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')),
2987+
)
2988+
28662989
RAG_EXTERNAL_RERANKER_URL = PersistentConfig(
28672990
'RAG_EXTERNAL_RERANKER_URL',
28682991
'rag.external_reranker_url',
@@ -3084,7 +3207,7 @@ class BannerModel(BaseModel):
30843207

30853208
WEB_FETCH_MAX_CONTENT_LENGTH = PersistentConfig(
30863209
'WEB_FETCH_MAX_CONTENT_LENGTH',
3087-
'rag.web.search.fetch_url_max_content_length',
3210+
'rag.web.fetch.max_content_length',
30883211
(int(os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH') else None),
30893212
)
30903213

@@ -3933,6 +4056,18 @@ class BannerModel(BaseModel):
39334056
os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3'),
39344057
)
39354058

4059+
AUDIO_TTS_MISTRAL_API_KEY = PersistentConfig(
4060+
'AUDIO_TTS_MISTRAL_API_KEY',
4061+
'audio.tts.mistral.api_key',
4062+
os.getenv('AUDIO_TTS_MISTRAL_API_KEY', ''),
4063+
)
4064+
4065+
AUDIO_TTS_MISTRAL_API_BASE_URL = PersistentConfig(
4066+
'AUDIO_TTS_MISTRAL_API_BASE_URL',
4067+
'audio.tts.mistral.api_base_url',
4068+
os.getenv('AUDIO_TTS_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'),
4069+
)
4070+
39364071

39374072
####################################
39384073
# LDAP

backend/open_webui/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ def __str__(self) -> str:
9191

9292
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
9393

94+
AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})'
95+
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
96+
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
97+
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
98+
99+
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
100+
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
101+
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
102+
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
103+
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
104+
94105

95106
class TASKS(str, Enum):
96107
def __str__(self) -> str:

0 commit comments

Comments
 (0)