From b1d40f340921c27eb9a965b9feeb2563856e25e2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 5 Jun 2026 16:58:16 -0400 Subject: [PATCH 001/346] refac --- backend/open_webui/main.py | 9 +++------ backend/open_webui/utils/middleware.py | 16 ++++++++-------- .../chat/Settings/Advanced/AdvancedParams.svelte | 12 ++++++++++-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index e05497c6165..07d7583002e 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1790,12 +1790,9 @@ async def chat_completion( 'stream_delta_chunk_size': stream_delta_chunk_size, 'reasoning_tags': reasoning_tags, 'function_calling': ( - 'native' - if ( - form_data.get('params', {}).get('function_calling') == 'native' - or model_info_params.get('function_calling') == 'native' - ) - else 'default' + form_data.get('params', {}).get('function_calling') + or model_info_params.get('function_calling') + or 'native' ), }, } diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 63de31fbbac..576afe3b24d 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2471,7 +2471,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if 'files' in folder.data: # Defensive: filter to entries the caller can still read. allowed_files = await get_accessible_folder_files(folder.data['files'], user) - if metadata.get('params', {}).get('function_calling') != 'native': + if metadata.get('params', {}).get('function_calling') == 'legacy': form_data['files'] = [ *allowed_files, *form_data.get('files', []), @@ -2485,7 +2485,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): user_message = get_last_user_message(form_data['messages']) model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', False) - if model_knowledge and metadata.get('params', {}).get('function_calling') != 'native': + if model_knowledge and metadata.get('params', {}).get('function_calling') == 'legacy': await event_emitter( { 'type': 'status', @@ -2563,17 +2563,17 @@ async def process_chat_payload(request, form_data, user, metadata, model): if 'memory' in features and features['memory']: # Skip forced memory injection when native FC is enabled - model can use memory tools - if metadata.get('params', {}).get('function_calling') != 'native': + if metadata.get('params', {}).get('function_calling') == 'legacy': form_data = await chat_memory_handler(request, form_data, extra_params, user) if 'web_search' in features and features['web_search']: # Skip forced RAG web search when native FC is enabled - model can use web_search tool - if metadata.get('params', {}).get('function_calling') != 'native': + if metadata.get('params', {}).get('function_calling') == 'legacy': form_data = await chat_web_search_handler(request, form_data, extra_params, user) if 'image_generation' in features and features['image_generation']: # Skip forced image generation when native FC is enabled - model can use generate_image tool - if metadata.get('params', {}).get('function_calling') != 'native': + if metadata.get('params', {}).get('function_calling') == 'legacy': form_data = await chat_image_generation_handler(request, form_data, extra_params, user) if 'code_interpreter' in features and features['code_interpreter']: @@ -2581,7 +2581,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Skip XML-tag prompt injection when native FC is enabled — # execute_code will be injected as a builtin tool instead - if metadata.get('params', {}).get('function_calling') != 'native': + if metadata.get('params', {}).get('function_calling') == 'legacy': prompt = ( request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != '' @@ -2843,7 +2843,7 @@ async def tool_function(**kwargs): builtin_tools_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( 'builtin_tools', True ) - if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled: + if metadata.get('params', {}).get('function_calling') != 'legacy' and builtin_tools_enabled: # Add file context to user messages chat_id = metadata.get('chat_id') form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user) @@ -2866,7 +2866,7 @@ async def tool_function(**kwargs): # (e.g. pipe functions) can access all tools including MCP and builtins. metadata['tools'] = tools_dict - if metadata.get('params', {}).get('function_calling') == 'native': + if metadata.get('params', {}).get('function_calling') != 'legacy': # If the function calling is native, then call the tools function calling handler form_data['tools'] = [ {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() diff --git a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte index 940011e0a30..39c29b36c16 100644 --- a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte +++ b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte @@ -150,7 +150,7 @@
{ - params.function_calling = (params?.function_calling ?? null) === null ? 'native' : null; + if ((params?.function_calling ?? null) === null) { + params.function_calling = 'native'; + } else if (params.function_calling === 'native') { + params.function_calling = 'legacy'; + } else { + params.function_calling = null; + } }} type="button" > {#if params.function_calling === 'native'} {$i18n.t('Native')} + {:else if params.function_calling === 'legacy'} + {$i18n.t('Legacy')} {:else} {$i18n.t('Default')} {/if} From 4602abe5c695fcfa752c2b520e4a468cf198d317 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 13 Jun 2026 02:13:39 +0100 Subject: [PATCH 002/346] refac --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 07494bd151c..32c6aa4c1b9 100644 --- a/.gitignore +++ b/.gitignore @@ -310,3 +310,4 @@ dist cypress/videos cypress/screenshots .vscode/settings.json +.cptr From f85cb27ef835aa76aff7de6176bf2159ba392061 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 13 Jun 2026 02:13:51 +0100 Subject: [PATCH 003/346] refac --- backend/open_webui/utils/headers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 72a5d4c4d1e..cb77636ca4d 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -64,9 +64,18 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) - return {} metadata = metadata or {} + + # Extract user_message info for tree mapping + user_message = metadata.get('user_message') or {} + user_message_id = metadata.get('user_message_id', '') or (user_message.get('id', '') if user_message else '') + user_message_parent_id = user_message.get('parentId', '') if user_message else '' + template_vars = { '{{CHAT_ID}}': metadata.get('chat_id', '') or '', '{{MESSAGE_ID}}': metadata.get('message_id', '') or '', + '{{USER_MESSAGE_ID}}': user_message_id or '', + '{{USER_MESSAGE_PARENT_ID}}': user_message_parent_id or '', + '{{TASK}}': metadata.get('task', '') or '', '{{USER_ID}}': (user.id if user else '') or '', '{{USER_NAME}}': (user.name if user else '') or '', '{{USER_EMAIL}}': (user.email if user else '') or '', From 5019af79a0c45743ede8c9ff37d68f768e7f6174 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:31:59 +0200 Subject: [PATCH 004/346] refac --- backend/open_webui/config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 57f4712027c..d8d7b9032e6 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2720,6 +2720,11 @@ def reachable(host: str, port: int) -> bool: os.getenv('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) +USER_PERMISSIONS_FOLDERS_ALLOW_SHARING = ( + os.getenv('USER_PERMISSIONS_FOLDERS_ALLOW_SHARING', 'False').lower() == 'true' +) + + USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = ( os.getenv('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) @@ -2837,6 +2842,7 @@ def reachable(host: str, port: int) -> bool: 'public_skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING, 'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING, 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, + 'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING, 'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING, 'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING, }, From 38920c0ed1f6ad5fe3bb9d12898fa968ead3634a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:32:06 +0200 Subject: [PATCH 005/346] refac --- src/lib/constants/permissions.ts | 1 + src/routes/+layout.svelte | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index b384e301b32..a925938a1a8 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -25,6 +25,7 @@ export const DEFAULT_PERMISSIONS = { public_skills: false, notes: false, public_notes: false, + folders: false, public_chats: false, public_calendars: false }, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9916fadf0a0..6fae7675ffd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -475,7 +475,7 @@ const type = event?.data?.type ?? null; const data = event?.data?.data ?? null; - // Calendar alerts are not chat-scoped — handle before chat_id checks + // Calendar alerts are not chat-scoped, handle before chat_id checks if (type === 'calendar:alert' && data) { const timeStr = data.minutes_until <= 0 From d65ac445a43348c5f0323d54c37397ae7f483cb8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:34:24 +0200 Subject: [PATCH 006/346] refac --- backend/open_webui/models/chats.py | 37 +++ backend/open_webui/models/folders.py | 66 ++++- backend/open_webui/routers/chats.py | 31 ++- backend/open_webui/routers/folders.py | 255 ++++++++++++++++-- .../utils/access_control/folders.py | 23 ++ 5 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 backend/open_webui/utils/access_control/folders.py diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f237ce58f7a..a8d3ce1e808 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1353,6 +1353,43 @@ async def get_chats_by_folder_id_and_user_id( for chat in all_chats ] + async def get_all_chats_by_folder_id( + self, + folder_id: str, + skip: int = 0, + limit: int = 60, + db: AsyncSession | None = None, + ) -> list[dict]: + """Get chats in a folder across ALL users. Returns dicts with user_id.""" + async with get_async_db_context(db) as session: + stmt = ( + select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at) + .filter_by(folder_id=folder_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .order_by(Chat.updated_at.desc(), Chat.id) + ) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await session.execute(stmt) + all_chats = result.all() + return [ + { + 'id': chat[0], + 'title': chat[1], + 'user_id': chat[2], + 'updated_at': chat[3], + 'created_at': chat[4], + 'last_read_at': chat[5], + } + for chat in all_chats + ] + + async def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None ) -> list[ChatModel]: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 1688b8bd461..d4eb52e337b 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context from pydantic import BaseModel, ConfigDict -from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select +from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -62,6 +62,20 @@ class FolderNameIdResponse(BaseModel): updated_at: int +class SharedFolderResponse(BaseModel): + id: str + name: str + parent_id: Optional[str] = None + user_id: str + owner_name: Optional[str] = None + permission: str = 'read' + access_grants: list = [] + is_expanded: bool = False + meta: Optional[dict] = None + created_at: int + updated_at: int + + #################### # Forms #################### @@ -130,6 +144,56 @@ async def get_folder_by_id_and_user_id( except Exception: return None + async def get_folder_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[FolderModel]: + """Fetch folder by ID only (no user_id filter). Used for shared access.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id)) + folder = result.scalars().first() + if not folder: + return None + return FolderModel.model_validate(folder) + except Exception: + return None + + async def get_shared_folder_ids_for_user( + self, user_id: str, user_group_ids: set[str], + db: Optional[AsyncSession] = None + ) -> dict[str, str]: + """ + Returns {folder_id: highest_permission} for all folders shared with user. + Checks direct user grants, group grants, and public (user:*) grants. + """ + from open_webui.models.access_grants import AccessGrant + + async with get_async_db_context(db) as db: + conditions = [ + and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'), + and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id), + ] + if user_group_ids: + conditions.append( + and_(AccessGrant.principal_type == 'group', + AccessGrant.principal_id.in_(user_group_ids)) + ) + result = await db.execute( + select(AccessGrant).filter( + AccessGrant.resource_type == 'folder', + or_(*conditions), + ) + ) + grants = result.scalars().all() + + # Build {folder_id: highest_permission} ('write' > 'read') + folder_perms = {} + for g in grants: + existing = folder_perms.get(g.resource_id) + if existing != 'write': + folder_perms[g.resource_id] = g.permission + return folder_perms + async def get_children_folders_by_id_and_user_id( self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[list[FolderModel]]: diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2689aa6d2f9..0ece8ec4107 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -32,6 +32,7 @@ from open_webui.socket.main import get_event_emitter from open_webui.tasks import stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.middleware import serialize_output from open_webui.utils.misc import get_message_list @@ -561,10 +562,13 @@ async def create_new_chat( # to assume the column is clean. Also catches non-UUID / nonexistent IDs. if form_data.folder_id is not None: if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # Check shared folder write access + shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) + if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) try: chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) @@ -951,6 +955,14 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess if has_grant: chat = await Chats.get_chat_by_id(id, db=db) + # Check folder-based access (shared folders) + if not chat: + candidate = await Chats.get_chat_by_id(id, db=db) + if candidate and candidate.folder_id: + folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) + if folder and await has_folder_access(user.id, folder, 'read', db): + chat = candidate + if chat: return ChatResponse(**chat.model_dump()) @@ -1493,10 +1505,13 @@ async def update_chat_folder_id_by_id( # folder_id values. None is allowed (moves the chat out of any folder). if form_data.folder_id is not None: if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # Check shared folder write access + shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) + if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) return ChatResponse(**chat.model_dump()) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 8d77de4894c..d0cf042bf35 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -19,7 +19,13 @@ Folders, FolderUpdateForm, ) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import Users from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import ( + filter_allowed_access_grants, +) from open_webui.utils.access_control.files import get_accessible_folder_files from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel @@ -31,6 +37,9 @@ router = APIRouter() +from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access + + ############################ # Get Folders ############################ @@ -101,6 +110,27 @@ async def create_folder( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) + # Check if creating a subfolder in a shared folder + if form_data.parent_id: + parent = await Folders.get_folder_by_id(form_data.parent_id, db=db) + if parent and parent.user_id != user.id: + # Creating subfolder in someone else's shared folder + if user.role != 'admin' and not await _has_folder_access(user.id, parent, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Create as the folder owner's subfolder (keep tree consistent) + try: + folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db) + return folder + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Error creating folder'), + ) + try: folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) return folder @@ -113,21 +143,85 @@ async def create_folder( ) +############################ +# Get Shared Folders +############################ + + +@router.get('/shared') +async def get_shared_folders( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """Get all folders shared with the current user (not owned by them).""" + groups = await Groups.get_groups_by_member_id(user.id, db=db) + group_ids = {g.id for g in groups} + + folder_perms = await Folders.get_shared_folder_ids_for_user( + user.id, group_ids, db=db + ) + + # Filter out folders owned by the user + results = [] + owner_cache = {} + for folder_id, permission in folder_perms.items(): + folder = await Folders.get_folder_by_id(folder_id, db=db) + if not folder or folder.user_id == user.id: + continue + + # Get owner name (cached) + if folder.user_id not in owner_cache: + owner = await Users.get_user_by_id(folder.user_id, db=db) + owner_cache[folder.user_id] = owner.name if owner else 'Unknown' + + results.append({ + **folder.model_dump(), + 'owner_name': owner_cache[folder.user_id], + 'permission': permission, + }) + + # Also include child folders of shared folders (inheritance) + shared_root_ids = {r['id'] for r in results} + for root_id in list(shared_root_ids): + root_folder = await Folders.get_folder_by_id(root_id, db=db) + if root_folder: + children = await Folders.get_children_folders_by_id_and_user_id( + root_id, root_folder.user_id, db=db + ) + if children: + for child in children: + if child.id not in {r['id'] for r in results}: + results.append({ + **child.model_dump(), + 'owner_name': owner_cache.get(child.user_id, 'Unknown'), + 'permission': folder_perms.get(root_id, 'read'), + }) + + return results + + ############################ # Get Folders By Id ############################ -@router.get('/{id}', response_model=Optional[FolderModel]) +@router.get('/{id}', response_model=None) async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: return folder - else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + + # Check shared access + folder = await Folders.get_folder_by_id(id, db=db) + if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)): + grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) + return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]} + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) ############################ @@ -143,11 +237,20 @@ async def update_folder_name_by_id( db: AsyncSession = Depends(get_async_session), ): folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + if not folder: + # Check shared write access + folder = await Folders.get_folder_by_id(id, db=db) + if not folder or (user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db)): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if folder: if form_data.name is not None: # Check if folder with same name exists existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( - folder.parent_id, user.id, form_data.name, db=db + folder.parent_id, folder.user_id, form_data.name, db=db ) if existing_folder and existing_folder.id != id: raise HTTPException( @@ -166,7 +269,7 @@ async def update_folder_name_by_id( ) try: - folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) + folder = await Folders.update_folder_by_id_and_user_id(id, folder.user_id, form_data, db=db) return folder except Exception as e: log.exception(e) @@ -175,11 +278,6 @@ async def update_folder_name_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), ) - else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) ############################ @@ -264,6 +362,96 @@ async def update_folder_is_expanded_by_id( ) +############################ +# Update Folder Access By Id +############################ + + +class FolderAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/{id}/access/update') +async def update_folder_access_by_id( + request: Request, + id: str, + form_data: FolderAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + folder = await Folders.get_folder_by_id(id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Only owner, admin, or write-granted user can update access + if user.role != 'admin' and user.id != folder.user_id: + if not await _has_folder_access(user.id, folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, user.role, + form_data.access_grants, + None, + db=db, + ) + + await AccessGrants.set_access_grants('folder', id, form_data.access_grants, db=db) + + grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) + return { + **folder.model_dump(), + 'access_grants': [g.model_dump() for g in grants], + } + + +############################ +# Get Shared Folder Chats +############################ + + +@router.get('/{id}/shared/chats') +async def get_shared_folder_chats( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """Get chats within a shared folder. Returns readonly flag based on permission.""" + folder = await Folders.get_folder_by_id(id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + is_owner = user.id == folder.user_id + is_admin = user.role == 'admin' + has_write = is_owner or is_admin or await _has_folder_access(user.id, folder, 'write', db) + has_read = has_write or await _has_folder_access(user.id, folder, 'read', db) + + if not has_read: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chats = await Chats.get_all_chats_by_folder_id(id, db=db) + + return { + 'chats': [ + {**chat, 'readonly': chat['user_id'] != user.id} + for chat in chats + ], + 'folder_permission': 'write' if has_write else 'read', + } + + ############################ # Delete Folder By Id ############################ @@ -277,7 +465,33 @@ async def delete_folder_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if await Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + + if not folder: + # Check if it's a shared subfolder with write access + folder = await Folders.get_folder_by_id(id, db=db) + if folder and folder.parent_id: + if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + elif folder and not folder.parent_id: + # Root shared folders can only be deleted by owner/admin + if user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + folder_owner_id = folder.user_id + + if await Chats.count_chats_by_folder_id_and_user_id(id, folder_owner_id, db=db): chat_delete_permission = await has_permission( user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db ) @@ -288,18 +502,21 @@ async def delete_folder_by_id( ) folders = [] - folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) + folders.append(folder) while folders: folder = folders.pop() if folder: try: - folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db) + folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, folder_owner_id, db=db) for folder_id in folder_ids: if delete_contents: - await Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db) + await Chats.delete_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, db=db) else: - await Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db) + await Chats.move_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, None, db=db) + + # Clean up access grants for this folder + await AccessGrants.revoke_all_access('folder', folder_id, db=db) return True except Exception as e: @@ -311,7 +528,7 @@ async def delete_folder_by_id( ) finally: # Get all subfolders - subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db) + subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, folder_owner_id, db=db) folders.extend(subfolders) else: diff --git a/backend/open_webui/utils/access_control/folders.py b/backend/open_webui/utils/access_control/folders.py new file mode 100644 index 00000000000..d4dd6e82fc7 --- /dev/null +++ b/backend/open_webui/utils/access_control/folders.py @@ -0,0 +1,23 @@ +from open_webui.models.access_grants import AccessGrants +from open_webui.models.folders import FolderModel, Folders +from sqlalchemy.ext.asyncio import AsyncSession + + +async def has_folder_access( + user_id: str, folder: FolderModel, permission: str, db: AsyncSession +) -> bool: + """Check if user has access to folder directly or via ancestor inheritance.""" + if await AccessGrants.has_access( + user_id=user_id, + resource_type='folder', + resource_id=folder.id, + permission=permission, + db=db, + ): + return True + # Check ancestor chain for inherited access + if folder.parent_id: + parent = await Folders.get_folder_by_id(folder.parent_id, db=db) + if parent: + return await has_folder_access(user_id, parent, permission, db) + return False From c783fd30f20d6be5028cf337bc5e5c2f9afbd3f8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:37:38 +0200 Subject: [PATCH 007/346] refac --- .../admin/Users/Groups/Permissions.svelte | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 6523419531b..d0845d398c8 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -393,6 +393,23 @@
{/if} +
+
+
+ {$i18n.t('Folders Sharing')} +
+ +
+ {#if defaultPermissions?.sharing?.folders && !permissions.sharing.folders} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ + {#if permissions.chat.share}
From 45fcf272ef51c84cb01c1454589da3f98e4adc2c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:42:29 +0200 Subject: [PATCH 008/346] refac --- src/lib/apis/folders/index.ts | 86 ++++++++++++ src/lib/components/chat/Chat.svelte | 16 ++- .../chat/Placeholder/FolderPlaceholder.svelte | 42 +++--- .../chat/Placeholder/FolderTitle.svelte | 7 + src/lib/components/layout/Sidebar.svelte | 32 ++++- .../components/layout/Sidebar/ChatItem.svelte | 6 +- .../components/layout/Sidebar/Folders.svelte | 62 +++++++-- .../layout/Sidebar/Folders/FolderMenu.svelte | 15 ++ .../Sidebar/Folders/FolderShareModal.svelte | 67 +++++++++ .../layout/Sidebar/RecursiveFolder.svelte | 77 +++++++---- .../layout/Sidebar/SharedFolderItem.svelte | 130 ++++++++++++++++++ 11 files changed, 474 insertions(+), 66 deletions(-) create mode 100644 src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte create mode 100644 src/lib/components/layout/Sidebar/SharedFolderItem.svelte diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index 5ac37a8251c..cd63d4d2e0e 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -234,3 +234,89 @@ export const deleteFolderById = async (token: string, id: string, deleteContents return res; }; + +export const updateFolderAccessById = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSharedFolders = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/shared`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return []; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSharedFolderChats = async (token: string, folderId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 69652dbe419..639a71ecfd3 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -169,6 +169,9 @@ let chat = null; let tags = []; + // Read-only when viewing someone else's chat (e.g. via shared folder access) + $: readOnly = chat != null && chat.user_id !== $user?.id; + let chatTasks = []; let history = { @@ -3103,6 +3106,7 @@
-
+ {#if readOnly} +
+
+ {$i18n.t('Read only')} +
+
+ {:else} +
+ {/if} {:else}
= getContext('i18n'); + import { user } from '$lib/stores'; + import { fade } from 'svelte/transition'; import ChatList from './ChatList.svelte'; import FolderKnowledge from './FolderKnowledge.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import { getChatListByFolderId } from '$lib/apis/chats'; + import { getSharedFolderChats } from '$lib/apis/folders'; export let folder: any = null; @@ -22,24 +25,8 @@ let allChatsLoaded = false; const loadChats = async () => { - chatListLoading = true; - - page += 1; - - let newChatList: any[] = []; - - newChatList = await getChatListByFolderId(localStorage.token, folder.id, page).catch( - (error) => { - console.error(error); - return []; - } - ); - - // once the bottom of the list has been reached (no results) there is no need to continue querying - allChatsLoaded = newChatList.length === 0; - chats = [...(chats || []), ...(newChatList || [])]; - - chatListLoading = false; + // getSharedFolderChats returns all users' chats in one shot; no pagination + allChatsLoaded = true; }; const setChatList = async () => { @@ -49,12 +36,21 @@ chatListLoading = false; if (folder && folder.id) { - const res = await getChatListByFolderId(localStorage.token, folder.id, page); - - if (res) { - chats = res; + // Always use the shared folder endpoint so owners also see + // chats created by users who have write access to this folder. + const res = await getSharedFolderChats(localStorage.token, folder.id).catch( + (error) => { + console.error(error); + return null; + } + ); + if (res && res.chats) { + chats = res.chats; + allChatsLoaded = true; } else { - chats = []; + // Fallback to regular API (e.g. if user has no shared access) + const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch(() => []); + chats = fallback || []; } } else { chats = []; diff --git a/src/lib/components/chat/Placeholder/FolderTitle.svelte b/src/lib/components/chat/Placeholder/FolderTitle.svelte index c48654accd7..7495f5bc09a 100644 --- a/src/lib/components/chat/Placeholder/FolderTitle.svelte +++ b/src/lib/components/chat/Placeholder/FolderTitle.svelte @@ -20,6 +20,7 @@ import { getChatsByFolderId } from '$lib/apis/chats'; import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte'; + import FolderShareModal from '$lib/components/layout/Sidebar/Folders/FolderShareModal.svelte'; import Folder from '$lib/components/icons/Folder.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; @@ -36,6 +37,7 @@ let showFolderModal = false; let showCreateSubFolderModal = false; + let showShareModal = false; let showDeleteConfirm = false; let deleteFolderContents = true; @@ -173,6 +175,8 @@ onSubmit={createSubFolderHandler} /> + + { showFolderModal = true; }} + onShare={() => { + showShareModal = true; + }} onDelete={() => { showDeleteConfirm = true; }} diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index f2329273aa2..2f91772191a 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -46,7 +46,7 @@ deleteAllChats, getChatListBySearchText } from '$lib/apis/chats'; - import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders'; + import { createNewFolder, getFolders, getSharedFolders, updateFolderParentIdById } from '$lib/apis/folders'; import { createNewNote, getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes'; import { updateUserSettings } from '$lib/apis/users'; import { checkActiveChats } from '$lib/apis/tasks'; @@ -61,6 +61,7 @@ import Folder from '../common/Folder.svelte'; import Tooltip from '../common/Tooltip.svelte'; import Folders from './Sidebar/Folders.svelte'; + import SharedFolderItem from './Sidebar/SharedFolderItem.svelte'; import { getChannels, createNewChannel } from '$lib/apis/channels'; import ChannelModal from './Sidebar/ChannelModal.svelte'; import ChannelItem from './Sidebar/ChannelItem.svelte'; @@ -98,12 +99,15 @@ let showPinnedNotes = false; let showChannels = false; let showFolders = false; + let showSharedFolders = false; let folders = {}; let folderRegistry = {}; let newFolderId = null; + let sharedFolders: any[] = []; + $: pinnedItems = $settings?.pinnedMenuItems ?? DEFAULT_PINNED_ITEMS; const isMenuItemVisible = (id) => { @@ -215,6 +219,31 @@ }); } } + + // Merge shared folders into the same structure + try { + sharedFolders = await getSharedFolders(localStorage.token); + } catch (e) { + sharedFolders = []; + } + + for (const sf of sharedFolders) { + if (folders[sf.id]) continue; // Already owned by user + folders[sf.id] = { ...sf, shared: true }; + } + + // Build parent-child relationships for shared folders + for (const sf of sharedFolders) { + if (folders[sf.id]?.shared && sf.parent_id && folders[sf.parent_id]) { + folders[sf.parent_id].childrenIds = folders[sf.parent_id].childrenIds + ? [...new Set([...folders[sf.parent_id].childrenIds, sf.id])] + : [sf.id]; + } + } + }; + + const initSharedFolders = async () => { + await initFolders(); }; const createFolder = async ({ name, data, parent_id }) => { @@ -291,6 +320,7 @@ scrollPaginationEnabled.set(false); initFolders(); + initSharedFolders(); await Promise.all([ await (async () => { console.log('Init tags'); diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index a0ebfb484c7..d89d24a8ac2 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -63,6 +63,7 @@ export let selected = false; export let shiftKey = false; + export let readonly = false; export let onDragEnd = () => {}; @@ -445,7 +446,7 @@ id="sidebar-chat-group" bind:this={itemElement} class=" w-full {className} relative group" - draggable={!confirmEdit} + draggable={!confirmEdit && !readonly} > {#if confirmEdit}
{ + if (readonly) return; e.preventDefault(); e.stopPropagation(); @@ -557,6 +559,7 @@ {/if} + {#if !readonly} + +
+ +
+
+ diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index fa80719d5a0..459175e5a99 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -19,7 +19,8 @@ updateFolderById, updateFolderParentIdById, getFolderById, - createNewFolder + createNewFolder, + getSharedFolderChats } from '$lib/apis/folders'; import { getChatById, @@ -39,6 +40,7 @@ import ChatItem from './ChatItem.svelte'; import FolderMenu from './Folders/FolderMenu.svelte'; + import FolderShareModal from './Folders/FolderShareModal.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import FolderModal from './Folders/FolderModal.svelte'; import Emoji from '$lib/components/common/Emoji.svelte'; @@ -63,6 +65,7 @@ let folderElement; let showFolderModal = false; + let showShareModal = false; let edit = false; let showCreateSubFolderModal = false; @@ -78,7 +81,7 @@ const onDragOver = (e) => { e.preventDefault(); e.stopPropagation(); - if (dragged || parentDragged) { + if (dragged || parentDragged || folders[folderId]?.shared) { return; } draggedOver = true; @@ -374,10 +377,18 @@ export const setFolderItems = async () => { await tick(); if (open) { - chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => { - toast.error(`${error}`); - return []; - }); + // Always use getSharedFolderChats so owners also see chats + // created by users who have write access to this folder. + try { + const res = await getSharedFolderChats(localStorage.token, folderId); + chats = res?.chats ?? []; + } catch (error) { + // Fallback to regular API + chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => { + toast.error(`${error}`); + return []; + }); + } } else { chats = null; } @@ -478,6 +489,8 @@ onSubmit={createSubFolderHandler} /> + + {#if dragged && x && y}
@@ -491,7 +504,7 @@ {/if} -
+
{#if draggedOver}
{ + if (folders[folderId]?.shared) return; if (clickTimer) { clearTimeout(clickTimer); // cancel the single-click action clickTimer = null; @@ -614,29 +628,34 @@ {/if}
- + { + showFolderModal = true; + }} + onShare={() => { + showShareModal = true; + }} + onDelete={() => { + showDeleteConfirm = true; + }} + onExport={() => { + exportHandler(); + }} + onCreateSubFolder={() => { + createSubFolderParentId = folderId; + showCreateSubFolderModal = true; + }} + > +
+ +
+
+ + {/if}
diff --git a/src/lib/components/layout/Sidebar/SharedFolderItem.svelte b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte new file mode 100644 index 00000000000..b7000695cb8 --- /dev/null +++ b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte @@ -0,0 +1,130 @@ + + +
+ +
+ +
+ + {#if expanded} +
+ {#if loading} +
+ + {$i18n.t('Loading...')} +
+ {:else} + {#each chats as chat (chat.id)} + + {/each} + + {#each children as child (child.id)} + + {/each} + + {#if chats.length === 0 && children.length === 0} +
+ {$i18n.t('Empty')} +
+ {/if} + {/if} +
+ {/if} +
From 76854d14246660af8222a5302513020e1f36c4f3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:53:52 +0200 Subject: [PATCH 009/346] refac --- backend/open_webui/routers/folders.py | 9 ++ src/lib/components/chat/Chat.svelte | 1 + src/lib/components/chat/Navbar.svelte | 4 +- src/lib/components/chat/Placeholder.svelte | 82 ++++++++-------- .../chat/Placeholder/ChatList.svelte | 14 ++- .../chat/Placeholder/FolderTitle.svelte | 95 +++++++++++-------- src/lib/components/layout/Navbar/Menu.svelte | 5 +- .../components/layout/Sidebar/ChatItem.svelte | 12 +++ .../layout/Sidebar/RecursiveFolder.svelte | 5 +- .../layout/Sidebar/SharedFolderItem.svelte | 2 + 10 files changed, 146 insertions(+), 83 deletions(-) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index d0cf042bf35..4f6c6cd10fc 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -443,6 +443,15 @@ async def get_shared_folder_chats( chats = await Chats.get_all_chats_by_folder_id(id, db=db) + # Resolve owner names for display (avatar URLs are constructed client-side) + owner_cache: dict[str, str] = {} + for chat in chats: + uid = chat['user_id'] + if uid not in owner_cache: + u = await Users.get_user_by_id(uid, db=db) + owner_cache[uid] = u.name if u else 'Unknown' + chat['owner_name'] = owner_cache[uid] + return { 'chats': [ {**chat, 'readonly': chat['user_id'] != user.id} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 639a71ecfd3..406dc222bf3 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -3030,6 +3030,7 @@ void) | null = null; @@ -122,7 +123,7 @@ " > {#if showModelSelector} - + {/if}
@@ -203,6 +204,7 @@ { showShareChatModal = !showShareChatModal; diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 4577ff9b6ba..83f9a18a697 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -73,6 +73,9 @@ } $: models = selectedModels.map((id) => $_models.find((m) => m.id === id)); + + // True when viewing a shared folder the current user doesn't own + $: folderReadOnly = $selectedFolder != null && $selectedFolder.user_id !== $user?.id;
@@ -94,18 +97,19 @@
{#if $selectedFolder} { - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - currentChatPage.set(1); - }} - onDelete={async () => { - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - currentChatPage.set(1); + folder={$selectedFolder} + readOnly={folderReadOnly} + onUpdate={async (folder) => { + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + currentChatPage.set(1); + }} + onDelete={async () => { + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + currentChatPage.set(1); - selectedFolder.set(null); - }} - /> + selectedFolder.set(null); + }} + /> {:else}
@@ -210,33 +214,35 @@ {/if}
- { - dispatch('submit', e.detail); - }} - /> + {#if !($selectedFolder && folderReadOnly)} + { + dispatch('submit', e.detail); + }} + /> + {/if}
diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte index d73bc30a9ac..3dfc1870895 100644 --- a/src/lib/components/chat/Placeholder/ChatList.svelte +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -9,6 +9,7 @@ import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import Loader from '$lib/components/common/Loader.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; dayjs.extend(localizedFormat); @@ -54,6 +55,7 @@ let orderBy = 'updated_at'; let direction = 'desc'; // 'asc' or 'desc' + $: if (chats) { init(); } @@ -157,10 +159,20 @@ {chat?.title}
- + {/if}
{/if} diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 43bdc2b745f..36357fd539f 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -39,6 +39,7 @@ const i18n = getContext('i18n'); export let shareEnabled: boolean = false; + export let readOnly: boolean = false; export let shareHandler: Function; export let moveChatHandler: Function; @@ -364,7 +365,7 @@
{/if} - {#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))} + {#if !readOnly && !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))}
- {#if !$temporaryChatEnabled && chat?.id} + {#if !readOnly && !$temporaryChatEnabled && chat?.id}
{#if $folders.length > 0} diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index d89d24a8ac2..d4f9ca8f0db 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -65,6 +65,9 @@ export let shiftKey = false; export let readonly = false; + export let ownerName: string | null = null; + export let ownerUserId: string | null = null; + export let onDragEnd = () => {}; function formatTimeAgo(timestamp: number): string { @@ -534,6 +537,15 @@ {/if}
+ {#if ownerUserId} + + + + {/if} {#if unread}
diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index 459175e5a99..979a1029ef2 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -11,7 +11,7 @@ import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; - import { chatId, mobile, selectedFolder, showSidebar } from '$lib/stores'; + import { chatId, mobile, selectedFolder, showSidebar, user } from '$lib/stores'; import { deleteFolderById, @@ -703,6 +703,9 @@ createdAt={chat.created_at} updatedAt={chat.updated_at} lastReadAt={chat.last_read_at} + ownerName={chat.owner_name ?? null} + ownerUserId={chat.owner_name ? chat.user_id : null} + readonly={chat.user_id !== $user?.id} {shiftKey} on:change={(e) => { dispatch('change', e.detail); diff --git a/src/lib/components/layout/Sidebar/SharedFolderItem.svelte b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte index b7000695cb8..1ae59f367b8 100644 --- a/src/lib/components/layout/Sidebar/SharedFolderItem.svelte +++ b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte @@ -108,6 +108,8 @@ title={chat.title} createdAt={chat.created_at} updatedAt={chat.updated_at} + ownerName={chat.owner_name} + ownerUserId={chat.user_id} readonly={chat.readonly ?? !isWritable} /> {/each} From 084d040e220ee39f62757d928d839646e813fb25 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 00:05:14 +0200 Subject: [PATCH 010/346] refac --- backend/open_webui/routers/folders.py | 3 +- src/lib/components/chat/Placeholder.svelte | 34 +++++++++++-------- .../components/layout/Sidebar/ChatItem.svelte | 19 ++++++----- .../Sidebar/Folders/FolderShareModal.svelte | 23 +++++++++++-- .../layout/Sidebar/RecursiveFolder.svelte | 6 ++-- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 4f6c6cd10fc..9a3615080c5 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -210,7 +210,8 @@ async def get_shared_folders( async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: - return folder + grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) + return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]} # Check shared access folder = await Folders.get_folder_by_id(id, db=db) diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 83f9a18a697..892bf7f0c95 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -74,8 +74,14 @@ $: models = selectedModels.map((id) => $_models.find((m) => m.id === id)); - // True when viewing a shared folder the current user doesn't own - $: folderReadOnly = $selectedFolder != null && $selectedFolder.user_id !== $user?.id; + // True when viewing a shared folder the current user doesn't own AND lacks write access + $: folderReadOnly = + $selectedFolder != null && + $selectedFolder.user_id !== $user?.id && + $selectedFolder.permission !== 'write'; + + // True when the current user does NOT own this folder (hide management menus) + $: folderNotOwned = $selectedFolder != null && $selectedFolder.user_id !== $user?.id;
@@ -97,19 +103,19 @@
{#if $selectedFolder} { - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - currentChatPage.set(1); - }} - onDelete={async () => { - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - currentChatPage.set(1); + folder={$selectedFolder} + readOnly={folderNotOwned} + onUpdate={async (folder) => { + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + currentChatPage.set(1); + }} + onDelete={async () => { + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + currentChatPage.set(1); - selectedFolder.set(null); - }} - /> + selectedFolder.set(null); + }} + /> {:else}
diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index d4f9ca8f0db..498dffbd9f1 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -529,6 +529,16 @@ on:focus={(e) => {}} draggable="false" > + {#if ownerUserId} + + + + {/if} + {#if $activeChatIds.has(id)}
@@ -537,15 +547,6 @@ {/if}
- {#if ownerUserId} - - - - {/if} {#if unread}
diff --git a/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte b/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte index e2029b322c0..318f7884e62 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte @@ -5,7 +5,7 @@ import Modal from '$lib/components/common/Modal.svelte'; import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; - import { updateFolderAccessById } from '$lib/apis/folders'; + import { getFolderById, updateFolderAccessById } from '$lib/apis/folders'; import { user } from '$lib/stores'; type AccessGrant = { @@ -19,11 +19,28 @@ export let folder: any = null; let accessGrants: AccessGrant[] = []; + let loading = false; - $: if (folder) { - accessGrants = folder.access_grants ?? []; + // Fetch fresh folder data (with access_grants) when modal opens + $: if (show && folder?.id) { + loadAccessGrants(); } + const loadAccessGrants = async () => { + loading = true; + try { + const freshFolder = await getFolderById(localStorage.token, folder.id); + if (freshFolder) { + accessGrants = freshFolder.access_grants ?? []; + } + } catch (e) { + console.error('Failed to load folder access grants', e); + accessGrants = folder?.access_grants ?? []; + } finally { + loading = false; + } + }; + const handleAccessChange = async () => { if (!folder) return; try { diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index 979a1029ef2..0523de415e7 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -549,7 +549,7 @@ }); if (folder) { - await selectedFolder.set(folder); + await selectedFolder.set({ ...folders[folderId], ...folder }); } await goto('/'); @@ -703,8 +703,8 @@ createdAt={chat.created_at} updatedAt={chat.updated_at} lastReadAt={chat.last_read_at} - ownerName={chat.owner_name ?? null} - ownerUserId={chat.owner_name ? chat.user_id : null} + ownerName={folders[folderId]?.shared ? (chat.owner_name ?? null) : null} + ownerUserId={folders[folderId]?.shared && chat.owner_name ? chat.user_id : null} readonly={chat.user_id !== $user?.id} {shiftKey} on:change={(e) => { From ecdb6f353a9d05e77fe14872472c7a42f23618b6 Mon Sep 17 00:00:00 2001 From: Andrew Baek <53360955+andrewbbaek@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:35:25 -0500 Subject: [PATCH 011/346] Merge pull request #26060 from andrewbbaek/dev feat: add auth-login-card ID for custom CSS targeting --- src/routes/auth/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index e1bae7c3b7f..1ccfecf1577 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -256,7 +256,7 @@
{:else}
-
+
{#if $config?.metadata?.auth_logo_position === 'center'}
Date: Tue, 16 Jun 2026 22:43:40 +0200 Subject: [PATCH 012/346] refac --- README.md | 80 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 3c4bee98c91..66c49eb3281 100644 --- a/README.md +++ b/README.md @@ -27,58 +27,84 @@ For more information, be sure to check out our [Open WebUI Documentation](https: ## Key Features of Open WebUI ⭐ -- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images. +- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments. -- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**. +- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely. -- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users. +- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group. -- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices. +- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more. -- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface. +- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/). -- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction. +- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection. -- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments. +- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control. -- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration. +- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next. -- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs. +- ✅ **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready. -- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions. +- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling. -- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query. +- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced. -- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience. +- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost. -- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions. +- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction. -- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows. +- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI). -- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel. +- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes. -- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators. +- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command. -- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments. +- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation. -- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance. +- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed. -- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management. +- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing. -- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage. +- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses. -- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack. +- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards. -- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers. +- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage. -- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors! +- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai. -- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more. +- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace. -- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features. +- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage. + +- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack. + +- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers. + +- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage! + +- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features. + +- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security). Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview! +## The Open WebUI Ecosystem 🌐 + +Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it: + +- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. + +- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes. + +- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more. + +- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync. + +- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine. + +Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details! + --- We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you! @@ -222,6 +248,10 @@ This project contains code under multiple licenses. The current codebase include If you have any questions, suggestions, or need assistance, please open an issue or join our [Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝 +## Security 🛡️ + +If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure! + ## Star History From 05098d25a58d03738e01c4e85e8852c3b4ad849c Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:44:11 +0200 Subject: [PATCH 013/346] Fail closed when the proxy/redirect path decode cap is exceeded (#26050) The decode-until-stable loops in _sanitize_proxy_path (terminals.py, cap 8) and _safe_static_redirect_path (models.py, cap 2) proceed with whatever remains after the cap instead of rejecting it. A path encoded more times than the cap therefore exits the loop still percent-encoded, passes the literal '..' / prefix checks (the dots are still %2E, not '..'), and is forwarded to the upstream terminal server, or emitted as a redirect Location, which then decodes it once more and resolves the traversal. This is the residual of the decode-until-stable hardening added for CVE-2026-54017: the cap is a fixed depth, not a true stability guarantee. Reject when the value is still not stable after the cap (unquote(x) != x), so anything encoded more deeply than the cap fails closed rather than being forwarded. Legitimate paths stabilize within a pass or two and are unaffected. Co-authored-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/routers/models.py | 3 +++ backend/open_webui/routers/terminals.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 75ee4e723be..75c37b0eb9b 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -60,6 +60,9 @@ def _safe_static_redirect_path(url: str) -> str | None: if decoded == path: break path = decoded + # Fail closed: a value still encoded after the cap would be decoded further downstream. + if unquote(path) != path: + return None if '\x00' in path or '\\' in path: return None if not path.startswith('/'): diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 6a942cf9b24..4c71322bfbf 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -43,6 +43,9 @@ def _sanitize_proxy_path(path: str) -> str | None: if once == decoded: break decoded = once + # Fail closed: still encoded after the cap means the upstream would decode further into traversal. + if unquote(decoded) != decoded: + return None had_trailing_slash = decoded.endswith('/') normalized = posixpath.normpath(decoded) # Remove any leading slashes that would reset the base From 920b655f4689e2118de928fbc936f6ebd4fed396 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:45:27 +0200 Subject: [PATCH 014/346] Gate scheduled automations and fail closed on per-model access for non-user roles (#26047) Two lifecycle/authorization gaps let a deactivated (pending) account keep acting: 1. The background automation scheduler (execute_automation) rehydrated the owner by ID and dispatched the chat pipeline without re-checking the owner. A user later set to pending, or one whose features.automations permission was revoked, kept running scheduled automations on the operator's provider credentials, even though the HTTP create/update/run routes already gate on get_verified_user + features.automations. Re-gate the rehydrated owner before dispatch: require role user/admin and, for non-admins, the features.automations permission; otherwise record an error and skip the run. 2. check_model_access enforced per-model ACLs only for exactly role == 'user', so any other non-admin role (a pending principal) fell through and was granted access. Enforce for every non-admin role (admins still bypass), so the check fails closed (CWE-862, CWE-863). Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/utils/access_control/__init__.py | 3 ++- backend/open_webui/utils/automations.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 1cc2f086f5f..d513a5c6716 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -321,7 +321,8 @@ async def check_model_access( return if model_info: - if user.role == 'user': + # Enforce for every non-admin role (including pending); never fail open. + if user.role != 'admin': from open_webui.models.access_grants import AccessGrants user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 10f90da7955..7db4d6e3ca5 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -359,6 +359,16 @@ async def execute_automation(app, automation: AutomationModel) -> None: await _record_run(automation.id, 'error', error='User not found') return + # Re-gate the rehydrated owner: a demoted/deactivated or de-permissioned owner must not run. + from open_webui.utils.access_control import has_permission + + if user.role not in ('user', 'admin') or ( + user.role != 'admin' + and not await has_permission(user.id, 'features.automations', app.state.config.USER_PERMISSIONS) + ): + await _record_run(automation.id, 'error', error='Owner no longer permitted to run automations') + return + prompt = await prompt_template(automation.data['prompt'], user) model_id = automation.data['model_id'] terminal_config = automation.data.get('terminal') From dc4924b66e655b315e3be4430a3e51b7d5c20acc Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:45:42 +0200 Subject: [PATCH 015/346] Enforce per-model access on arena fallback before bypass_filter dispatch (#26046) generate_chat_completion() checks model access on line 200 only when bypass_filter is False. When an arena model reaches this function without a pre-resolved selected_model_id, which is the task and background path (the /api/v1/tasks/* endpoints call generate_chat_completion directly rather than through process_chat_payload), the fallback resolves the arena to an underlying model and recurses with bypass_filter=True, so the resolved model's access check is skipped. An authenticated user with access to an arena could therefore reach a model they are denied directly, and for the default or exclude arena, whose candidate pool is every non-arena model, any model on the instance (CWE-862). The normal chat path resolves the arena in process_chat_payload before this function, so its resolved model is checked on line 200; the task path was not, which is the inconsistency. Enforce check_model_access() on the resolved model in the fallback, before the bypass_filter=True recursion, mirroring the normal-path check. Admins and already-bypassed recursive calls are unaffected, and legitimate arena use of accessible models is unchanged. Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/utils/chat.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 248be33be79..196689f61dd 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -237,6 +237,12 @@ async def generate_chat_completion( form_data['model'] = selected_model_id + # bypass_filter recursion below skips the line-200 check; gate the resolved model here. + if not bypass_filter and user.role == 'user': + selected_model = request.app.state.MODELS.get(selected_model_id) + if selected_model: + await check_model_access(user, selected_model) + if selected_model_id: if form_data.get('stream') == True: From bb66d435b7dad5db95683f35173741bf1adc8aa3 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:48:44 -0400 Subject: [PATCH 016/346] fix(auth): enforce features.api_keys permission on GET and DELETE /api_key endpoints (#25992) --- backend/open_webui/routers/auths.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 434a6349de9..5b4ec857a47 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1237,20 +1237,26 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D ############################ -# create api key -@router.post('/api_key', response_model=ApiKey) -async def generate_api_key( - request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) -): +async def _check_api_key_permission(request: Request, user, db: AsyncSession): if not request.app.state.config.ENABLE_API_KEYS or ( user.role != 'admin' - and not await has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS) + and not await has_permission( + user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS, db=db + ) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED, ) + +# create api key +@router.post('/api_key', response_model=ApiKey) +async def generate_api_key( + request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) +): + await _check_api_key_permission(request, user, db) + api_key = create_api_key() success = await Users.update_user_api_key_by_id(user.id, api_key, db=db) @@ -1264,13 +1270,19 @@ async def generate_api_key( # delete api key @router.delete('/api_key', response_model=bool) -async def delete_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)): +async def delete_api_key( + request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) +): + await _check_api_key_permission(request, user, db) return await Users.delete_user_api_key_by_id(user.id, db=db) # get api key @router.get('/api_key', response_model=ApiKey) -async def get_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)): +async def get_api_key( + request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) +): + await _check_api_key_permission(request, user, db) api_key = await Users.get_user_api_key_by_id(user.id, db=db) if api_key: return { From 19a176fd36bea15c49d7f2d1539b4832e57a8bc2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 22:53:57 +0200 Subject: [PATCH 017/346] refac --- backend/open_webui/routers/folders.py | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 9a3615080c5..0adb0687727 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -40,23 +40,13 @@ from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access -############################ -# Get Folders -############################ - - -@router.get('/', response_model=list[FolderNameIdResponse]) -async def get_folders( - request: Request, - user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), -): +async def check_folders_permission(request: Request, user, db=None): + """Verify the folders feature is enabled and the user has permission.""" if request.app.state.config.ENABLE_FOLDERS is False: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != 'admin' and not await has_permission( user.id, 'features.folders', @@ -68,6 +58,20 @@ async def get_folders( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + +############################ +# Get Folders +############################ + + +@router.get('/', response_model=list[FolderNameIdResponse]) +async def get_folders( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_folders_permission(request, user, db=db) + folders = await Folders.get_folders_by_user_id(user.id, db=db) # Verify folder data integrity @@ -96,10 +100,12 @@ async def get_folders( @router.post('/') async def create_folder( + request: Request, form_data: FolderForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( form_data.parent_id, user.id, form_data.name, db=db ) @@ -155,6 +161,7 @@ async def get_shared_folders( db: AsyncSession = Depends(get_async_session), ): """Get all folders shared with the current user (not owned by them).""" + await check_folders_permission(request, user, db=db) groups = await Groups.get_groups_by_member_id(user.id, db=db) group_ids = {g.id for g in groups} @@ -207,7 +214,8 @@ async def get_shared_folders( @router.get('/{id}', response_model=None) -async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): +async def get_folder_by_id(request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) @@ -232,11 +240,13 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSe @router.post('/{id}/update') async def update_folder_name_by_id( + request: Request, id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if not folder: # Check shared write access @@ -292,11 +302,13 @@ class FolderParentIdForm(BaseModel): @router.post('/{id}/update/parent') async def update_folder_parent_id_by_id( + request: Request, id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( @@ -337,11 +349,13 @@ class FolderIsExpandedForm(BaseModel): @router.post('/{id}/update/expanded') async def update_folder_is_expanded_by_id( + request: Request, id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: try: @@ -380,6 +394,7 @@ async def update_folder_access_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id(id, db=db) if not folder: raise HTTPException( @@ -419,11 +434,13 @@ async def update_folder_access_by_id( @router.get('/{id}/shared/chats') async def get_shared_folder_chats( + request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): """Get chats within a shared folder. Returns readonly flag based on permission.""" + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id(id, db=db) if not folder: raise HTTPException( @@ -475,6 +492,7 @@ async def delete_folder_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if not folder: From 84e4110538149421dd1b0c08709e2369851a5ff3 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:54:35 -0400 Subject: [PATCH 018/346] fix(auth): enforce chat.tts permission on OpenAI /audio/speech proxy endpoint (#25993) --- backend/open_webui/routers/openai.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 8aa8eff36e9..1977a2cb3f5 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -37,7 +37,7 @@ from open_webui.models.groups import Groups from open_webui.models.models import Models from open_webui.models.users import UserModel -from open_webui.utils.access_control import check_model_access, has_connection_access +from open_webui.utils.access_control import check_model_access, has_connection_access, has_permission from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_custom_headers, include_user_info_headers @@ -289,6 +289,14 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe @router.post('/audio/speech') async def speech(request: Request, user=Depends(get_verified_user)): + if user.role != 'admin' and not await has_permission( + user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + idx = None try: idx = request.app.state.config.OPENAI_API_BASE_URLS.index('https://api.openai.com/v1') From 1fc0e3ade79510d5b03c231e48b1ff118bd7b2b4 Mon Sep 17 00:00:00 2001 From: Martin Gronek <150715021+SmartMart69@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:55:23 +0200 Subject: [PATCH 019/346] fix: correct German date format token for dayjs in de-DE translation (#25985) TT and JJJJ are German placeholders but not valid dayjs format tokens. Replaced with DD and YYYY which dayjs understands correctly. --- src/lib/i18n/locales/de-DE/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/i18n/locales/de-DE/translation.json b/src/lib/i18n/locales/de-DE/translation.json index bc583f19583..499a1b995b8 100644 --- a/src/lib/i18n/locales/de-DE/translation.json +++ b/src/lib/i18n/locales/de-DE/translation.json @@ -519,7 +519,7 @@ "Datalab Marker API": "Datalab Marker API", "Date Modified": "Datum geändert", "Day": "Tag", - "DD/MM/YYYY": "TT.MM.JJJJ", + "DD/MM/YYYY": "DD.MM.YYYY", "DDGS Backend": "DDGS Backend", "December": "Dezember", "Decrease UI Scale": "UI-Skalierung verringern", From b7626f05fb92b24ab923ad81a037071ebd5623d1 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:56:24 +0200 Subject: [PATCH 020/346] Authorize KB write access before auto-linking an uploaded file (CWE-862/863) (#26001) process_uploaded_file auto-links an uploaded file to the knowledge base named in client-supplied metadata.knowledge_id, but it called Knowledges.add_file_to_knowledge_by_id directly with no authorization, while the dedicated POST /knowledge/{id}/file/add endpoint gates the same operation on owner / admin / write grant. A read-only collaborator (or any verified user who knows the KB id) could therefore attach arbitrary files to a knowledge base they cannot write: the KnowledgeFile membership row was committed, and the later vector-index write check failed closed but left the row in place. Gate the auto-link on the same owner/admin/write check before inserting the membership row; otherwise skip the link and log. The file still uploads as the user's own file, it just is not linked to a KB they cannot write. Co-authored-by: jagstack <52110932+jagstack@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/routers/files.py | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index dbf1ccb885a..f5f8df66ddd 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -178,19 +178,39 @@ async def _process_handler(db_session): knowledge_id = file_metadata.get('knowledge_id') if knowledge_id: try: - await Knowledges.add_file_to_knowledge_by_id( - knowledge_id=knowledge_id, - file_id=file_item.id, - user_id=user.id, - directory_id=file_metadata.get('directory_id'), + # Gate like POST /knowledge/{id}/file/add: a client-supplied + # metadata.knowledge_id must not let a non-writer attach files (CWE-862/863). + knowledge = await Knowledges.get_knowledge_by_id(id=knowledge_id, db=db_session) + can_write = bool(knowledge) and ( + knowledge.user_id == user.id + or user.role == 'admin' + or await AccessGrants.has_access( + user_id=user.id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='write', + db=db_session, + ) ) - await process_file( - request, - ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id), - user=user, - db=db_session, - ) - log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}') + if not can_write: + log.warning( + f'Refusing to auto-link file {file_item.id} to knowledge ' + f'{knowledge_id}: user {user.id} lacks write access' + ) + else: + await Knowledges.add_file_to_knowledge_by_id( + knowledge_id=knowledge_id, + file_id=file_item.id, + user_id=user.id, + directory_id=file_metadata.get('directory_id'), + ) + await process_file( + request, + ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id), + user=user, + db=db_session, + ) + log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}') except Exception as e: log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}') From b93d5607881f0ab3a86cd3a9b7bc0d6bd86bca70 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:56:56 -0400 Subject: [PATCH 021/346] feat(ui): pre-select last model when adding a new model slot (#25974) --- src/lib/components/chat/ModelSelector.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/components/chat/ModelSelector.svelte b/src/lib/components/chat/ModelSelector.svelte index bd609b344f5..48b3d85833b 100644 --- a/src/lib/components/chat/ModelSelector.svelte +++ b/src/lib/components/chat/ModelSelector.svelte @@ -79,7 +79,10 @@ class=" " {disabled} on:click={() => { - selectedModels = [...selectedModels, '']; + selectedModels = [ + ...selectedModels, + selectedModels[selectedModels.length - 1] || '' + ]; }} aria-label="Add Model" > From 78a5015846a9e55ff2bc9d6cc98f880437abe8ed Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 23:00:31 +0200 Subject: [PATCH 022/346] refac --- src/lib/components/admin/Users/Groups.svelte | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index c9c57ea5829..f02adf8fb32 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -221,13 +221,15 @@ {/if}
- + {#if showDefaultPermissionsModal} + + {/if} - + diff --git a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte index 0c8090f0d47..0e235c3baa5 100644 --- a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte @@ -1,5 +1,5 @@ {#if user} From 087878ce848a4d828012068b5997dac480f43656 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:53:08 +0200 Subject: [PATCH 032/346] Match WEB_FETCH_FILTER_LIST on hostnames with label boundaries, not URL suffix (CWE-693) (#25949) is_string_allowed does endswith() matching and was called with the full URL (retrieval/web/utils.py) against WEB_FETCH_FILTER_LIST, so a blocklisted host with any path (https://blocked.example/x) ended with /x, not the host, and slipped through; the allowlist direction false-rejected legitimate URLs and admitted attacker URLs ending in an allowed string. The same endswith caused label confusion at the hostname call site (retrieval/web/main.py): corp.com matched evilcorp.com, 10.0.0.1 matched 110.0.0.1. Add is_host_allowed(host, ...) matching on DNS label boundaries (host == pattern or host.endswith('.' + pattern)), called with the parsed hostname at both web-fetch call sites. is_string_allowed is left unchanged for the unrelated function-name filters (utils/middleware.py, utils/tools.py). The separate is_global guard (validate_url / _ssrf_safe_new_conn, active when ENABLE_RAG_LOCAL_WEB_FETCH is off) already blocks RFC1918/loopback/link-local, so this restores the admin's intended blocking of specific public hosts. Co-authored-by: addcontent <59762500+addcontent@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/retrieval/web/main.py | 4 +-- backend/open_webui/retrieval/web/utils.py | 6 ++-- backend/open_webui/utils/misc.py | 38 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index a55c62c8b57..56f796db589 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -4,7 +4,7 @@ import validators from open_webui.retrieval.web.utils import resolve_hostname -from open_webui.utils.misc import is_string_allowed +from open_webui.utils.misc import is_host_allowed from pydantic import BaseModel @@ -32,7 +32,7 @@ def get_filtered_results(results, filter_list): except Exception: pass - if is_string_allowed(hostnames, filter_list): + if is_host_allowed(hostnames, filter_list): filtered_results.append(result) continue diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 3e4bc8a25dc..c5fa1c07388 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -55,7 +55,7 @@ from open_webui.retrieval.loaders.external_web import ExternalWebLoader from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url -from open_webui.utils.misc import is_string_allowed +from open_webui.utils.misc import is_host_allowed log = logging.getLogger(__name__) @@ -93,7 +93,9 @@ def validate_url(url: Union[str, Sequence[str]]): # Blocklist check using unified filtering logic if WEB_FETCH_FILTER_LIST: - if not is_string_allowed(url, WEB_FETCH_FILTER_LIST): + # Match on the parsed hostname, not the full URL: a path component would + # otherwise let any URL slip past a hostname-based block/allow entry. + if not is_host_allowed(parsed_url.hostname, WEB_FETCH_FILTER_LIST): log.warning(f'URL blocked by filter list: {url}') raise ValueError(ERROR_MESSAGES.INVALID_URL) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index d5d8078bed7..c5c6528d102 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -69,6 +69,44 @@ def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str | return True +def _host_matches_pattern(host: str, pattern: str) -> bool: + """Match a hostname against a filter entry on DNS label boundaries. + + `pattern` matches `host` when equal or a parent domain of it, so `corp.com` + matches `api.corp.com` but not `evilcorp.com`, and an IP literal matches only + itself. Avoids the raw-suffix confusion of a plain endswith. + """ + host = (host or '').strip().lower().rstrip('.') + pattern = (pattern or '').strip().lower().rstrip('.') + if not host or not pattern: + return False + return host == pattern or host.endswith('.' + pattern) + + +def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: + """Allow/block a hostname (or list of hostnames / resolved IPs) against a + WEB_FETCH_FILTER_LIST-style filter, matching on label boundaries. + + Pass a parsed hostname, never a full URL: matching against a URL lets a path + component defeat the filter (e.g. ``https://blocked.example/x`` ends with ``/x``, + not the blocked host). Entries prefixed with ``!`` are blocked; the rest form an allowlist. + """ + if not filter_list: + return True + + allow_list, block_list = get_allow_block_lists(filter_list) + hosts = [host] if isinstance(host, str) else list(host or []) + + if allow_list: + if not any(_host_matches_pattern(h, allowed) for h in hosts for allowed in allow_list): + return False + + if any(_host_matches_pattern(h, blocked) for h in hosts for blocked in block_list): + return False + + return True + + def get_message_list(messages_map, message_id): """ Reconstructs a list of messages in order up to the specified message_id. From ec86ce5cf735a1a064d8963d18bb44a61c3728e0 Mon Sep 17 00:00:00 2001 From: Jamie Lin Date: Wed, 17 Jun 2026 05:53:38 +0800 Subject: [PATCH 033/346] fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow (#25958) * fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow The Dynamic Client Registration flow seeded the registration request `scope` from the Authorization Server's `scopes_supported` (RFC 8414), which is a full catalog of every scope the AS can grant across all resources. Per RFC 9728 and the MCP Scope Selection Strategy, the resource-specific Protected Resource Metadata `scopes_supported` is the correct, least-privilege source. The PRM is already fetched in this function; this change prefers its `scopes_supported` and keeps the AS `scopes_supported` only as a fallback when the PRM advertises none. Mirrors the static-credentials fix in #24690. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(oauth): trim DCR scope comment per review feedback Shortens the inline comment per review feedback on #25958. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/utils/oauth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index f2e1aa14f69..dfddd487e44 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -435,6 +435,13 @@ async def get_oauth_client_info_with_dynamic_client_registration( # Attempt to fetch OAuth server metadata to get registration endpoint & scopes resource_metadata = await get_protected_resource_metadata(oauth_server_url) resource = resource_metadata.resource + + # Prefer the resource-specific scopes from the Protected Resource Metadata + # (RFC 9728) over the AS's full scopes_supported catalog, for least + # privilege. Mirrors the static-credentials flow (#24690). + if resource_metadata.scopes_supported: + oauth_client_metadata.scope = ' '.join(resource_metadata.scopes_supported) + discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url) for url in discovery_urls: async with aiohttp.ClientSession(trust_env=True) as session: From fbcdcf146b99b5002705060a8243eee769108f9e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 23:59:24 +0200 Subject: [PATCH 034/346] refac --- backend/open_webui/routers/channels.py | 41 ++++++++++++++++--- .../channel/ChannelInfoModal/UserList.svelte | 1 - 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 11d3a4a8713..706ca58e182 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -31,9 +31,7 @@ from open_webui.models.users import ( UserIdNameResponse, UserIdNameStatusResponse, - UserListResponse, UserModel, - UserModelResponse, UserNameResponse, Users, ) @@ -440,7 +438,40 @@ async def get_channel_by_id( PAGE_ITEM_COUNT = 30 -@router.get('/{id}/members', response_model=UserListResponse) +class ChannelMemberResponse(BaseModel): + id: str + email: str + name: str + role: str + profile_image_url: str | None = None + presence_state: str | None = None + status_emoji: str | None = None + status_message: str | None = None + status_expires_at: int | None = None + is_active: bool = False + + +class ChannelMemberListResponse(BaseModel): + users: list[ChannelMemberResponse] + total: int + + +def serialize_channel_member(user: UserModel) -> ChannelMemberResponse: + return ChannelMemberResponse( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + profile_image_url=user.profile_image_url, + presence_state=user.presence_state, + status_emoji=user.status_emoji, + status_message=user.status_message, + status_expires_at=user.status_expires_at, + is_active=Users.is_active(user), + ) + + +@router.get('/{id}/members', response_model=ChannelMemberListResponse) async def get_channel_members_by_id( request: Request, id: str, @@ -475,7 +506,7 @@ async def get_channel_members_by_id( total = len(fetched_users) return { - 'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users], + 'users': [serialize_channel_member(u) for u in fetched_users], 'total': total, } else: @@ -503,7 +534,7 @@ async def get_channel_members_by_id( total = result['total'] return { - 'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users], + 'users': [serialize_channel_member(u) for u in fetched_users], 'total': total, } diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte index 354d1002df3..80fc7cf45ff 100644 --- a/src/lib/components/channel/ChannelInfoModal/UserList.svelte +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -15,7 +15,6 @@ import Pagination from '$lib/components/common/Pagination.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import Badge from '$lib/components/common/Badge.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; From 4c06b392da7b878c8bcc5e903bfcba5ba14e60e7 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:59:55 +0200 Subject: [PATCH 035/346] Don't let SCIM's active flag demote an admin (defense-in-depth) (#25948) The SCIM update_user (PUT) and patch_user (PATCH) handlers mapped the SCIM active field unconditionally onto the role column (role = 'user' if active else 'pending'), so a routine IdP sync or a misconfigured IdP that marked a locally-provisioned admin inactive would silently strip that admin's role and could lock an instance out of its own administration. Gate both active->role assignments on user.role != 'admin' so SCIM provisioning can activate/deactivate ordinary users but never demotes an existing admin; admin role changes continue to go through the dedicated admin endpoints. SCIM already cannot promote to admin (active only maps to user/pending), so this is symmetric. Credit to @HOHK0923 for surfacing the admin-demotion footgun. Co-authored-by: HOHK0923 <118590749+HOHK0923@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/routers/scim.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 9292523adc1..e36afef1736 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -672,7 +672,10 @@ async def update_user( if user_data.emails and len(user_data.emails) > 0: update_data['email'] = user_data.emails[0].value - if user_data.active is not None: + # Do not let SCIM's active flag demote an existing admin: a routine IdP sync or misconfiguration + # must not silently strip a locally-provisioned admin's role and lock the instance out. Admin + # role changes go through the dedicated admin endpoints, not SCIM provisioning. + if user_data.active is not None and user.role != 'admin': update_data['role'] = 'user' if user_data.active else 'pending' if user_data.photos and len(user_data.photos) > 0: @@ -719,7 +722,9 @@ async def patch_user( if op == 'replace': if path == 'active': - update_data['role'] = 'user' if value else 'pending' + # Same guard as update_user: never demote an existing admin via SCIM. + if user.role != 'admin': + update_data['role'] = 'user' if value else 'pending' elif path == 'userName': update_data['email'] = value elif path == 'displayName': From 993e74912199c66c522f08ec81abe31d76985e39 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:05:45 +0200 Subject: [PATCH 036/346] refac --- backend/open_webui/models/auths.py | 3 +++ backend/open_webui/utils/auth.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 5e363352bd1..96f6c397034 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -8,6 +8,7 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users +from open_webui.utils.auth import PLACEHOLDER_HASH from open_webui.utils.validate import validate_profile_image_url from pydantic import BaseModel, field_validator from sqlalchemy import Boolean, Column, String, Text, delete, select, update @@ -142,11 +143,13 @@ async def authenticate_user( log.info('authenticate_user: %s', email) resolved = await Users.get_user_by_email(email, db=db) if not resolved: + verify_password(PLACEHOLDER_HASH) return # load the credential row and verify the password hash async with get_async_db_context(db) as session: credential = await session.get(Auth, resolved.id) if not credential or not credential.active: + verify_password(PLACEHOLDER_HASH) return if not verify_password(credential.password): return diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 26cea6b45fc..85e6706d959 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -162,6 +162,12 @@ def get_password_hash(password: str) -> str: return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') +# Pre-computed hash verified on signin paths that lack a real credential +# (unknown user, inactive account) so response timing cannot reveal +# whether an account exists (CWE-208). +PLACEHOLDER_HASH = get_password_hash('placeholder') + + def validate_password(password: str) -> bool: # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing. if len(password.encode('utf-8')) > 72: From 22f2fe1ffb66c993dad1e0b2b35514acaed2370e Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:06:13 +0200 Subject: [PATCH 037/346] Require auth on ydoc:awareness:update and ydoc:document:leave handlers (CWE-306) (#25946) These were the only two ydoc Socket.IO handlers missing the SESSION_POOL guard that every sibling (join/state/update) enforces. With always_connect=True admitting unauthenticated sockets, a client that knew a note's document_id could broadcast spoofed awareness (cursors/presence) and fake ydoc:user:left events into a live editing room, attributed to any client-supplied user_id. Both handlers now require an authenticated SESSION_POOL session; the awareness handler additionally requires prior ydoc:document:join (room membership); and the broadcast user_id is fixed to the authenticated identity instead of the client-supplied value, removing the impersonation. Document content was never reachable (ydoc:document:update already enforces write permission). Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/socket/main.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 2884847a0e9..33333778859 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -757,11 +757,13 @@ async def debounced_save(): @sio.on('ydoc:document:leave') async def yjs_document_leave(sid, data): """Handle user leaving a document""" + user = SESSION_POOL.get(sid) + if not user: # authenticated session required (parity with sibling handlers) + return try: document_id = normalize_document_id(data['document_id']) - user_id = data.get('user_id', sid) - log.info(f'User {user_id} leaving document {document_id}') + log.info(f'User {user["id"]} leaving document {document_id}') # Remove user from the document await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid) @@ -769,10 +771,10 @@ async def yjs_document_leave(sid, data): # Leave Socket.IO room await sio.leave_room(sid, f'doc_{document_id}') - # Notify other users + # Notify other users; user_id is the authenticated identity, not client-supplied await sio.emit( 'ydoc:user:left', - {'document_id': document_id, 'user_id': user_id}, + {'document_id': document_id, 'user_id': user['id']}, room=f'doc_{document_id}', ) @@ -787,16 +789,21 @@ async def yjs_document_leave(sid, data): @sio.on('ydoc:awareness:update') async def yjs_awareness_update(sid, data): """Handle awareness updates (cursors, selections, etc.)""" + user = SESSION_POOL.get(sid) + if not user: # authenticated session required (parity with sibling handlers) + return try: - document_id = data['document_id'] - user_id = data.get('user_id', sid) + document_id = normalize_document_id(data['document_id']) + room = f'doc_{document_id}' + if room not in sio.rooms(sid): # must have joined the document first + return update = data['update'] - # Broadcast awareness update to all other users in the document + # Broadcast to the room; user_id is the authenticated identity, not client-supplied await sio.emit( 'ydoc:awareness:update', - {'document_id': document_id, 'user_id': user_id, 'update': update}, - room=f'doc_{document_id}', + {'document_id': document_id, 'user_id': user['id'], 'update': update}, + room=room, skip_sid=sid, ) From ee3a49a88deb87b14d1663f91a26348749b82f94 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:07:11 -0400 Subject: [PATCH 038/346] fix(ui): deduplicate store fetches with null-guards and mutex (#25943) --- .../Analytics/AnalyticsModelModal.svelte | 3 +- .../admin/Evaluations/LeaderboardModal.svelte | 3 +- .../channel/MessageInput/MentionList.svelte | 4 - src/lib/components/chat/Chat.svelte | 190 +++++++++--------- src/lib/components/chat/MessageInput.svelte | 5 - .../workspace/Models/ModelEditor.svelte | 8 +- 6 files changed, 106 insertions(+), 107 deletions(-) diff --git a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte index adcc6f33dae..4c6ea3e3934 100644 --- a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte +++ b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte @@ -150,7 +150,8 @@ selectedTab = 'overview'; chatList = []; allChatsLoaded = false; - selectRange(selectedRange); + selectedRange = '30d'; + loadOverview(30); } diff --git a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte index 6730e739d73..e71dd7ae56c 100644 --- a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte +++ b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte @@ -51,7 +51,8 @@ // Load history when model changes and modal is shown $: if (show && model?.id) { - selectRange(selectedRange); + selectedRange = '30d'; + loadHistory(30); } // Use top_tags from backend response (already computed) diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index 0d8ce881970..3f8d893fa7b 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -117,10 +117,6 @@ .map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c })) ]; } else { - if (userSuggestions) { - getUserList(); - } - if (modelSuggestions) { _models = [ ...$models diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index ddbf6527234..b189d388f71 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -113,7 +113,6 @@ import Tooltip from '../common/Tooltip.svelte'; import Sidebar from '../icons/Sidebar.svelte'; import Image from '../common/Image.svelte'; - import { getBanners } from '$lib/apis/configs'; export let chatIdProp = ''; @@ -330,107 +329,117 @@ ); }; + let settingDefaults = false; const setDefaults = async () => { - if (!$tools) { - tools.set(await getTools(localStorage.token)); - } - if (!$functions) { - functions.set(await getFunctions(localStorage.token)); - } - if (!$skills) { - skills.set(await getSkills(localStorage.token)); - } - if (selectedModels.length !== 1 && !atSelectedModel) { - return; - } + if (settingDefaults) return; + settingDefaults = true; - const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]); - if (model) { - // Set Default Tools - if (model?.info?.meta?.toolIds) { - const defaultIds = [ - ...new Set( - [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id)) - ) - ]; - - // Separate unauthenticated OAuth tools - const unauthed = []; - const authed = []; - for (const id of defaultIds) { - const tool = $tools.find((t) => t.id === id); - if (tool && tool.authenticated === false) { - const parts = id.split(':'); - const serverId = parts.at(-1) ?? id; - const authType = - parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null; - unauthed.push({ id, name: tool.name ?? id, serverId, authType }); - } else { - authed.push(id); - } - } - selectedToolIds = authed; - pendingOAuthTools = unauthed; - } else if ($settings?.tools) { - selectedToolIds = $settings.tools; - } else { - selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:')); + try { + if (!$tools) { + tools.set(await getTools(localStorage.token)); + } + if (!$functions) { + functions.set(await getFunctions(localStorage.token)); + } + if (!$skills) { + skills.set(await getSkills(localStorage.token)); + } + if (selectedModels.length !== 1 && !atSelectedModel) { + return; } - // Set Default Skills - if (model?.info?.meta?.skillIds) { - selectedSkillIds = [ - ...new Set( - [...(model?.info?.meta?.skillIds ?? [])].filter((id) => - ($skills ?? []).find((s) => s.id === id && s.is_active) + const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]); + if (model) { + // Set Default Tools + if (model?.info?.meta?.toolIds) { + const defaultIds = [ + ...new Set( + [...(model?.info?.meta?.toolIds ?? [])].filter((id) => + $tools.find((t) => t.id === id) + ) ) - ) - ]; - } else { - selectedSkillIds = []; - } + ]; - // Set Default Filters (Toggleable only) - if (model?.info?.meta?.defaultFilterIds) { - selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) => - model?.filters?.find((f) => f.id === id) - ); - } + // Separate unauthenticated OAuth tools + const unauthed = []; + const authed = []; + for (const id of defaultIds) { + const tool = $tools.find((t) => t.id === id); + if (tool && tool.authenticated === false) { + const parts = id.split(':'); + const serverId = parts.at(-1) ?? id; + const authType = + parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null; + unauthed.push({ id, name: tool.name ?? id, serverId, authType }); + } else { + authed.push(id); + } + } + selectedToolIds = authed; + pendingOAuthTools = unauthed; + } else if ($settings?.tools) { + selectedToolIds = $settings.tools; + } else { + selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:')); + } - // Set Default Features - if (model?.info?.meta?.defaultFeatureIds) { - if ( - model.info?.meta?.capabilities?.['image_generation'] && - $config?.features?.enable_image_generation && - ($user?.role === 'admin' || $user?.permissions?.features?.image_generation) - ) { - imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation'); + // Set Default Skills + if (model?.info?.meta?.skillIds) { + selectedSkillIds = [ + ...new Set( + [...(model?.info?.meta?.skillIds ?? [])].filter((id) => + ($skills ?? []).find((s) => s.id === id && s.is_active) + ) + ) + ]; + } else { + selectedSkillIds = []; } - if ( - model.info?.meta?.capabilities?.['web_search'] && - $config?.features?.enable_web_search && - ($user?.role === 'admin' || $user?.permissions?.features?.web_search) - ) { - webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search'); + // Set Default Filters (Toggleable only) + if (model?.info?.meta?.defaultFilterIds) { + selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) => + model?.filters?.find((f) => f.id === id) + ); } - if ( - model.info?.meta?.capabilities?.['code_interpreter'] && - $config?.features?.enable_code_interpreter && - ($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter) - ) { - codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter'); + // Set Default Features + if (model?.info?.meta?.defaultFeatureIds) { + if ( + model.info?.meta?.capabilities?.['image_generation'] && + $config?.features?.enable_image_generation && + ($user?.role === 'admin' || $user?.permissions?.features?.image_generation) + ) { + imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation'); + } + + if ( + model.info?.meta?.capabilities?.['web_search'] && + $config?.features?.enable_web_search && + ($user?.role === 'admin' || $user?.permissions?.features?.web_search) + ) { + webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search'); + } + + if ( + model.info?.meta?.capabilities?.['code_interpreter'] && + $config?.features?.enable_code_interpreter && + ($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter) + ) { + codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter'); + } } - } - // Set Default Terminal — only if the referenced terminal actually exists - if (model?.info?.meta?.terminalId) { - const tid = model.info.meta.terminalId; - if (isTerminalAvailable(tid)) { - selectedTerminalId.set(tid); + // Set Default Terminal — only if the referenced terminal actually exists + if (model?.info?.meta?.terminalId) { + const tid = model.info.meta.terminalId; + if (isTerminalAvailable(tid)) { + selectedTerminalId.set(tid); + } } } + } finally { + settingDefaults = false; } }; @@ -796,13 +805,6 @@ if (p.url.pathname === '/') { await tick(); initNewChat(); - - // Re-fetch banners on navigation to homepage so newly configured banners appear - try { - banners.set(await getBanners(localStorage.token).catch(() => [])); - } catch (e) { - console.error('Failed to refresh banners:', e); - } } stopAudio(); diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 646abf24f45..ffe959fc4e6 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -58,8 +58,6 @@ import { deleteFileById } from '$lib/apis/files'; import { getChatById } from '$lib/apis/chats'; import { getSessionUser } from '$lib/apis/auths'; - import { getTools } from '$lib/apis/tools'; - import { getSkills } from '$lib/apis/skills'; import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs'; @@ -1106,9 +1104,6 @@ dropzoneElement.addEventListener('drop', onDrop, true); dropzoneElement.addEventListener('dragleave', onDragLeave); } - - tools.set(await getTools(localStorage.token)); - skills.set(await getSkills(localStorage.token)); }; initialize(); diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 274f42bb6cf..9c41aff0dd5 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -248,9 +248,13 @@ }; onMount(async () => { - await tools.set(await getTools(localStorage.token)); + if (!$tools) { + await tools.set(await getTools(localStorage.token)); + } skillsList = (await getSkills(localStorage.token).catch(() => null)) ?? []; - await functions.set(await getFunctions(localStorage.token)); + if (!$functions) { + await functions.set(await getFunctions(localStorage.token)); + } // Fetch admin-configured default model metadata so the editor // reflects the actual defaults rather than hardcoded values From 966b3fdb570347d6a5bc84ebe878b29d83312e87 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:09:50 -0400 Subject: [PATCH 039/346] fix(ui): deduplicate layout-level API requests (#25942) --- src/lib/components/admin/Settings/General.svelte | 12 ++++-------- src/routes/(app)/+layout.svelte | 3 +++ src/routes/(app)/admin/settings/+page.svelte | 3 --- src/routes/+layout.svelte | 12 ------------ 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index ddb81e844b6..77500c8a2a5 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -29,10 +29,10 @@ export let saveHandler: Function; - let updateAvailable = null; + let updateAvailable = false; let version = { - current: '', - latest: '' + current: WEBUI_VERSION, + latest: WEBUI_VERSION }; let adminConfig = null; @@ -106,10 +106,6 @@ }; onMount(async () => { - if ($config?.features?.enable_version_update_check) { - checkForVersionUpdates(); - } - await Promise.all([ (async () => { adminConfig = await getAdminConfig(localStorage.token); @@ -129,7 +125,7 @@ const ldapConfig = await getLdapConfig(localStorage.token); ENABLE_LDAP = ldapConfig.ENABLE_LDAP; - banners = await getBanners(localStorage.token); + banners = [...$_banners]; }); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 772ae6fe2d3..f0e3a8dbbbd 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -14,6 +14,7 @@ import { getBanners } from '$lib/apis/configs'; import { getTerminalServers } from '$lib/apis/terminal'; import { getUserSettings } from '$lib/apis/users'; + import { setTextScale } from '$lib/utils/text-scale'; import { WEBUI_VERSION, WEBUI_API_BASE_URL } from '$lib/constants'; import { compareVersion } from '$lib/utils'; @@ -105,6 +106,8 @@ settings.set(userSettings.ui); } + setTextScale($settings?.textScale ?? 1); + if (cb) { await cb(); } diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte index d8a497cb246..2ebe22b6b1f 100644 --- a/src/routes/(app)/admin/settings/+page.svelte +++ b/src/routes/(app)/admin/settings/+page.svelte @@ -1,11 +1,8 @@ - - diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6fae7675ffd..0a165a89c89 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -993,18 +993,6 @@ $socket?.on('events', chatEventHandler); $socket?.on('events:channel', channelEventHandler); - const userSettings = await getUserSettings(localStorage.token); - if (userSettings) { - settings.set(userSettings.ui); - } else { - try { - settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}')); - } catch { - settings.set({}); - } - } - setTextScale($settings?.textScale ?? 1); - // Set up the token expiry check if (tokenTimer) { clearInterval(tokenTimer); From 8b35ce924b120ac7c6eec0eb6119243f23c6202b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:15:04 +0200 Subject: [PATCH 040/346] refac --- backend/open_webui/utils/task.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 0b5f6283058..fe3c8923f1f 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -141,6 +141,9 @@ def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate') - start: keep first max_chars characters - end: keep last max_chars characters """ + if max_chars <= 0: + return '' + if not content or len(content) <= max_chars: return content From 5f3a628a8d291bb5d33e1a0b0c89fb62a2927934 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:15:39 +0200 Subject: [PATCH 041/346] Encode terminal ws session_id to block upstream user_id query injection (#26042) ws_terminal() interpolated the path parameter session_id directly into the upstream terminal WebSocket URL and then appended ?user_id=, with no encoding or validation (the HTTP sibling proxy_terminal runs _sanitize_proxy_path; this path ran nothing). An encoded '?'/'&' smuggled through session_id survives Open WebUI's single decode and is re-decoded by the upstream, injecting an attacker-chosen user_id ahead of the appended one. Query parsing binds the first occurrence, so the orchestrator resolves the spoofed user's terminal scope, letting a normal authenticated user present another user's identity to the upstream (CWE-116/863). Encode session_id as an opaque path segment with urllib.parse.quote(session_id, safe=''). This neutralises '?'/'#'/'&' at any decode depth (the upstream's single decode reverses only the quote, leaving the original delimiters inert as path content), while legitimate UUID session ids pass through unchanged. The appended user_id is then the only query parameter the upstream binds. The separate concern that the forwarded identity is a bearer claim with no integrity binding spans Open WebUI and the upstream terminal server and is not addressed here. Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/routers/terminals.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 4c71322bfbf..7dc91ba8a55 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -285,10 +285,14 @@ async def ws_terminal( import urllib.parse + # Encode session_id as an opaque path segment so it cannot smuggle '?'/'#'/'&' (at any + # decode depth) and inject an attacker-chosen user_id ahead of the one appended below. + safe_session_id = urllib.parse.quote(session_id, safe='') + if policy_id: - upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{session_id}' + upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}' else: - upstream_url = f'{ws_base}/api/terminals/{session_id}' + upstream_url = f'{ws_base}/api/terminals/{safe_session_id}' if upstream_params: upstream_url += f'?{urllib.parse.urlencode(upstream_params)}' From 6f2e97aa58ae043d69ea7b3abdd2086135c25026 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:16:27 -0400 Subject: [PATCH 042/346] fix(evaluations): prevent duplicate leaderboard API requests on navigation (#25934) --- src/routes/(app)/admin/evaluations/+page.svelte | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/(app)/admin/evaluations/+page.svelte b/src/routes/(app)/admin/evaluations/+page.svelte index 176e3162f8e..b0c6ca903c5 100644 --- a/src/routes/(app)/admin/evaluations/+page.svelte +++ b/src/routes/(app)/admin/evaluations/+page.svelte @@ -2,11 +2,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; - import Evaluations from '$lib/components/admin/Evaluations.svelte'; - onMount(() => { goto('/admin/evaluations/leaderboard'); }); - - From fc9c2ea1915accd1f6edca467e965283dff71cd7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:17:14 +0200 Subject: [PATCH 043/346] refac --- src/lib/components/admin/Functions.svelte | 6 ++++-- .../admin/Users/Groups/Users.svelte | 12 +++++++---- .../components/admin/Users/UserList.svelte | 12 +++++++---- .../channel/ChannelInfoModal/UserList.svelte | 8 ++++--- src/lib/components/notes/Notes.svelte | 6 ++++-- src/lib/components/workspace/Knowledge.svelte | 6 ++++-- .../workspace/Knowledge/KnowledgeBase.svelte | 21 +++++++------------ .../Models/Knowledge/KnowledgeSelector.svelte | 6 ++++-- src/lib/components/workspace/Prompts.svelte | 14 ++++++++----- src/lib/components/workspace/Skills.svelte | 14 ++++++++----- src/lib/components/workspace/Tools.svelte | 6 ++++-- .../workspace/common/MemberSelector.svelte | 5 +++-- src/routes/(app)/automations/+page.svelte | 16 +++++++++----- 13 files changed, 81 insertions(+), 51 deletions(-) diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 37922ad640e..3e3e69f5374 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -71,12 +71,12 @@ let functions = null; let filteredItems = []; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { setFilteredItems(); }, 300); - } + }; $: if (functions && selectedType !== undefined && viewOption !== undefined) { setFilteredItems(); @@ -363,6 +363,7 @@ @@ -372,6 +373,7 @@ class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition" on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ace5141a7fc..b4e3c7d727a 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -84,13 +84,16 @@ getUserList(); } - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - page = 1; - getUserList(); + if (page !== 1) { + page = 1; + } else { + getUserList(); + } }, 300); - } + }; onDestroy(() => { clearTimeout(searchDebounceTimer); @@ -106,6 +109,7 @@
diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 40c7cffb07a..2a536d56a5c 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -100,13 +100,16 @@ } }; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - page = 1; - getUserList(); + if (page !== 1) { + page = 1; + } else { + getUserList(); + } }, 300); - } + }; $: if (page !== null && orderBy !== null && direction !== null) { getUserList(); @@ -210,6 +213,7 @@ diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte index 80fc7cf45ff..a72473ee826 100644 --- a/src/lib/components/channel/ChannelInfoModal/UserList.svelte +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -78,13 +78,14 @@ } }; - // Debounce only query changes - $: if (query !== undefined && channel !== null) { + const handleSearchInput = () => { + if (channel === null) return; + clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { getUserList(); }, 300); - } + }; // Immediate response to page/sort changes $: if (channel !== null && page && orderBy && direction) { @@ -142,6 +143,7 @@
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index e7762cf2588..64ffaee1833 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -180,14 +180,14 @@ await getItemsPage(); }; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { if (loaded) { init(); } }, 300); - } + }; $: if (loaded && sortKey !== undefined && permission !== undefined && viewOption !== undefined) { init(); @@ -380,6 +380,7 @@ @@ -389,6 +390,7 @@ class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition" on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index b2e11791241..b4c34889776 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -45,12 +45,12 @@ let allItemsLoaded = false; let itemsLoading = false; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { init(); }, 300); - } + }; onDestroy(() => { clearTimeout(searchDebounceTimer); @@ -199,6 +199,7 @@ @@ -209,6 +210,7 @@ aria-label={$i18n.t('Clear search')} on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 1cdadd05ced..82935f20800 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -142,14 +142,17 @@ await getItemsPage(); }; - // Debounce only query changes - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - getItemsPage(); + if (currentPage !== 1) { + currentPage = 1; + } else { + getItemsPage(); + } }, 300); - } + }; // Immediate response to filter/pagination changes $: if ( @@ -163,15 +166,6 @@ getItemsPage(); } - $: if ( - query !== undefined && - viewOption !== undefined && - sortKey !== undefined && - direction !== undefined - ) { - reset(); - } - const getItemsPage = async () => { if (knowledgeId === null) return; @@ -1313,6 +1307,7 @@ { diff --git a/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte b/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte index 5677eae8c47..9e8d7d3d1e9 100644 --- a/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte +++ b/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte @@ -34,12 +34,12 @@ $: items = [...noteItems, ...knowledgeItems, ...fileItems]; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { getItems(); }, 300); - } + }; onDestroy(() => { clearTimeout(searchDebounceTimer); @@ -111,6 +111,7 @@ if (e.detail === false) { onClose(); query = ''; + handleSearchInput(); } }} > @@ -128,6 +129,7 @@
diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 7974ee8ed3e..f9deed219c9 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -59,15 +59,17 @@ let page = 1; - // Debounce only query changes - $: if (query !== undefined) { + const handleSearchInput = () => { loading = true; clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - page = 1; - getPromptList(); + if (page !== 1) { + page = 1; + } else { + getPromptList(); + } }, 300); - } + }; // Immediate response to page/filter changes $: if (page && selectedTag !== undefined && viewOption !== undefined) { @@ -332,6 +334,7 @@ @@ -343,6 +346,7 @@ aria-label={$i18n.t('Clear search')} on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte index 96ab15a49be..ffb9b534af2 100644 --- a/src/lib/components/workspace/Skills.svelte +++ b/src/lib/components/workspace/Skills.svelte @@ -78,15 +78,17 @@ } }; - // Debounce only query changes - $: if (query !== undefined) { + const handleSearchInput = () => { loading = true; clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - page = 1; - loadSkillItems(); + if (page !== 1) { + page = 1; + } else { + loadSkillItems(); + } }, 300); - } + }; // Immediate response to page/filter changes $: if (page && viewOption !== undefined) { @@ -321,6 +323,7 @@ @@ -331,6 +334,7 @@ aria-label={$i18n.t('Clear search')} on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/workspace/Tools.svelte b/src/lib/components/workspace/Tools.svelte index 50462c1cb48..d68a32211d5 100644 --- a/src/lib/components/workspace/Tools.svelte +++ b/src/lib/components/workspace/Tools.svelte @@ -63,12 +63,12 @@ let showImportModal = false; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { setFilteredItems(); }, 300); - } + }; $: if (tools && viewOption !== undefined) { setFilteredItems(); @@ -325,6 +325,7 @@ @@ -335,6 +336,7 @@ aria-label={$i18n.t('Clear search')} on:click={() => { query = ''; + handleSearchInput(); }} > diff --git a/src/lib/components/workspace/common/MemberSelector.svelte b/src/lib/components/workspace/common/MemberSelector.svelte index f4acad13f7f..26ed4c4b9a1 100644 --- a/src/lib/components/workspace/common/MemberSelector.svelte +++ b/src/lib/components/workspace/common/MemberSelector.svelte @@ -63,12 +63,12 @@ } }; - $: if (query !== undefined) { + const handleSearchInput = () => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { getUserList(); }, 300); - } + }; onDestroy(() => { clearTimeout(searchDebounceTimer); @@ -187,6 +187,7 @@
diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 0fef27bd3e4..7ef8246eb46 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -50,15 +50,19 @@ let page = 1; - // Debounce only query changes (gate behind loaded to prevent double-fetch on mount) - $: if (loaded && query !== undefined) { + const handleSearchInput = () => { + if (!loaded) return; + loading = true; clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - page = 1; - getAutomationList(); + if (page !== 1) { + page = 1; + } else { + getAutomationList(); + } }, 300); - } + }; // Immediate response to page/filter changes (gate behind loaded) $: if (loaded && page && statusFilter !== undefined) { @@ -296,6 +300,7 @@ { query = ''; + handleSearchInput(); }} > From 7ee75a0c04a31528954903e88c9213d5fbb31aa7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:17:48 +0200 Subject: [PATCH 044/346] refac --- backend/open_webui/models/feedbacks.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 74844457500..5f971b55eb4 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -222,12 +222,19 @@ async def get_feedback_items( ) -> FeedbackListResponse: async with get_async_db_context(db) as db: stmt = select(Feedback, User).join(User, Feedback.user_id == User.id) + count_stmt = ( + select(func.count(Feedback.id)) + .select_from(Feedback) + .join(User, Feedback.user_id == User.id) + ) if filter: # Apply model_id filter (exact match) model_id = filter.get('model_id') if model_id: - stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id) + model_id_filter = Feedback.data['model_id'].as_string() == model_id + stmt = stmt.filter(model_id_filter) + count_stmt = count_stmt.filter(model_id_filter) order_by = filter.get('order_by') direction = filter.get('direction') @@ -256,9 +263,9 @@ async def get_feedback_items( else: stmt = stmt.order_by(Feedback.created_at.desc()) - # Count BEFORE pagination - count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) - total = count_result.scalar() + # Count before pagination without wrapping the ordered item query. + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 if skip: stmt = stmt.offset(skip) From 6b2d962cd6e58616f3fc3627fca2802d8d0b182b Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:25:20 -0400 Subject: [PATCH 045/346] feat(permissions): add reset to defaults button in permissions modals (#25931) Adds a 'Reset to Defaults' button to both the Edit Default Permissions modal and the Edit User Group permissions modal. - Default permissions modal: resets to stock/env-var configuration - User group modal: resets to current global default permissions Backend: new GET /api/v1/users/default/permissions/defaults endpoint that returns DEFAULT_USER_PERMISSIONS (env-var-based initial defaults). --- backend/open_webui/routers/users.py | 14 +++++ src/lib/apis/users/index.ts | 27 ++++++++ .../admin/Users/Groups/EditGroupModal.svelte | 61 ++++++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 0b8da713df0..158d6e8e15f 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -269,6 +269,20 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi return request.app.state.config.USER_PERMISSIONS +@router.get('/default/permissions/defaults', response_model=UserPermissions) +async def get_default_user_permissions_defaults(user=Depends(get_admin_user)): + from open_webui.config import DEFAULT_USER_PERMISSIONS + + return { + 'workspace': WorkspacePermissions(**DEFAULT_USER_PERMISSIONS.get('workspace', {})), + 'sharing': SharingPermissions(**DEFAULT_USER_PERMISSIONS.get('sharing', {})), + 'access_grants': AccessGrantsPermissions(**DEFAULT_USER_PERMISSIONS.get('access_grants', {})), + 'chat': ChatPermissions(**DEFAULT_USER_PERMISSIONS.get('chat', {})), + 'features': FeaturesPermissions(**DEFAULT_USER_PERMISSIONS.get('features', {})), + 'settings': SettingsPermissions(**DEFAULT_USER_PERMISSIONS.get('settings', {})), + } + + ############################ # GetUserSettingsBySessionUser ############################ diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 267aa69d22c..fb68ab8f614 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -85,6 +85,33 @@ export const updateUserDefaultPermissions = async (token: string, permissions: o return res; }; +export const getUserDefaultPermissionsDefaults = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions/defaults`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateUserRole = async (token: string, id: string, role: string) => { let error = null; diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index d446f9f62a3..622409a3daf 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -10,9 +10,11 @@ import Users from './Users.svelte'; import GroupPreviewPanel from './GroupPreviewPanel.svelte'; import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions'; + import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users'; import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; export let onSubmit: Function = () => {}; @@ -31,6 +33,7 @@ let selectedTab = 'general'; let loading = false; let showDeleteConfirmDialog = false; + let showResetConfirmDialog = false; let userCount = 0; @@ -56,6 +59,29 @@ show = false; }; + const resetToDefaultsHandler = async () => { + try { + // For user groups: reset to current global default permissions + // For default permissions modal: reset to stock/env-var configuration + const defaults = custom + ? await getUserDefaultPermissions(localStorage.token) + : await getUserDefaultPermissionsDefaults(localStorage.token); + if (defaults) { + permissions = { + workspace: { ...DEFAULT_PERMISSIONS.workspace, ...defaults.workspace }, + sharing: { ...DEFAULT_PERMISSIONS.sharing, ...defaults.sharing }, + access_grants: { ...DEFAULT_PERMISSIONS.access_grants, ...defaults.access_grants }, + chat: { ...DEFAULT_PERMISSIONS.chat, ...defaults.chat }, + features: { ...DEFAULT_PERMISSIONS.features, ...defaults.features }, + settings: { ...DEFAULT_PERMISSIONS.settings, ...defaults.settings } + }; + toast.success($i18n.t('Permissions reset to defaults')); + } + } catch (error) { + toast.error(`${error}`); + } + }; + const init = () => { if (group) { name = group.name; @@ -94,6 +120,17 @@ }} /> + { + resetToDefaultsHandler(); + }} +/> +
@@ -250,7 +287,29 @@
{#if ['general', 'permissions'].includes(selectedTab)} -
+
+
+ {#if selectedTab === 'permissions'} + + + + {/if} +
+
+ {#if $user?.role === 'admin' || ($user.permissions?.chat?.import ?? true)} +
+
+
{$i18n.t('Import Chats')}
+ +
-
+ {/if} {#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index a925938a1a8..af52a04bf01 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -47,6 +47,7 @@ export const DEFAULT_PERMISSIONS = { edit: true, share: true, export: true, + import: true, stt: true, tts: true, call: true, From 9ccda6715c3b2dc2cbc1302d2396b2f0233bdea8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:35:08 +0200 Subject: [PATCH 053/346] refac --- backend/open_webui/routers/chats.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 5017f821e2c..497f288818e 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -43,6 +43,17 @@ router = APIRouter() + +async def require_chat_import_permission(request: Request, user, db: AsyncSession): + if user.role != 'admin' and not await has_permission( + user.id, 'chat.import', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + ############################ # GetChatList # Let the record outlive the session, so that what was @@ -590,13 +601,7 @@ async def import_chats( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not await has_permission( - user.id, 'chat.import', request.app.state.config.USER_PERMISSIONS, db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await require_chat_import_permission(request, user, db) try: chats = await Chats.import_chats(user.id, form_data.chats, db=db) @@ -1219,11 +1224,14 @@ class CloneForm(BaseModel): @router.post('/{id}/clone', response_model=ChatResponse | None) async def clone_chat_by_id( + request: Request, form_data: CloneForm, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + await require_chat_import_permission(request, user, db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = { @@ -1267,8 +1275,13 @@ async def clone_chat_by_id( @router.post('/{id}/clone/shared', response_model=ChatResponse | None) async def clone_shared_chat_by_id( - id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), ): + await require_chat_import_permission(request, user, db) + chat = await Chats.get_chat_by_share_id(id, db=db) # Fallback: admins can also access any chat directly by chat ID From caedcbae4988ef59ea7052b2a3198e2da4b5291a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:36:34 +0200 Subject: [PATCH 054/346] refac --- backend/open_webui/utils/anthropic.py | 151 +++++++++++++++++--------- 1 file changed, 98 insertions(+), 53 deletions(-) diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index a1b00aab8d0..9e8bc54cf05 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -89,6 +89,26 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di ############################## +def _copy_cache_control(source: dict, target: dict) -> dict: + if isinstance(source, dict) and 'cache_control' in source: + target['cache_control'] = source['cache_control'] + return target + + +def _has_cache_control(blocks: list) -> bool: + return any(isinstance(block, dict) and 'cache_control' in block for block in blocks) + + +def _finalize_openai_content(blocks: list) -> str | list: + if not blocks: + return '' + + if len(blocks) == 1 and blocks[0].get('type') == 'text' and not _has_cache_control(blocks): + return blocks[0].get('text', '') + + return blocks + + def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: """ Convert an Anthropic Messages API request to OpenAI Chat Completions format. @@ -112,14 +132,21 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: if isinstance(system, str): messages.append({'role': 'system', 'content': system}) elif isinstance(system, list): - # Anthropic supports system as list of content blocks - text_parts = [] + openai_content = [] for block in system: if isinstance(block, dict) and block.get('type') == 'text': - text_parts.append(block.get('text', '')) + openai_content.append( + _copy_cache_control( + block, + { + 'type': 'text', + 'text': block.get('text', ''), + }, + ) + ) elif isinstance(block, str): - text_parts.append(block) - messages.append({'role': 'system', 'content': '\n'.join(text_parts)}) + openai_content.append({'type': 'text', 'text': block}) + messages.append({'role': 'system', 'content': _finalize_openai_content(openai_content)}) # Convert messages for msg in anthropic_payload.get('messages', []): @@ -138,10 +165,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: if block_type == 'text': openai_content.append( - { - 'type': 'text', - 'text': block.get('text', ''), - } + _copy_cache_control( + block, + { + 'type': 'text', + 'text': block.get('text', ''), + }, + ) ) elif block_type == 'image': source = block.get('source', {}) @@ -149,19 +179,25 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: media_type = source.get('media_type', 'image/png') data = source.get('data', '') openai_content.append( - { - 'type': 'image_url', - 'image_url': { - 'url': f'data:{media_type};base64,{data}', + _copy_cache_control( + block, + { + 'type': 'image_url', + 'image_url': { + 'url': f'data:{media_type};base64,{data}', + }, }, - } + ) ) elif source.get('type') == 'url': openai_content.append( - { - 'type': 'image_url', - 'image_url': {'url': source.get('url', '')}, - } + _copy_cache_control( + block, + { + 'type': 'image_url', + 'image_url': {'url': source.get('url', '')}, + }, + ) ) elif block_type == 'tool_use': tool_calls.append( @@ -196,10 +232,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: if content_type == 'text': converted_parts.append( - { - 'type': 'text', - 'text': content_block.get('text', ''), - } + _copy_cache_control( + content_block, + { + 'type': 'text', + 'text': content_block.get('text', ''), + }, + ) ) elif content_type == 'image': source = content_block.get('source', {}) @@ -207,21 +246,27 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: media_type = source.get('media_type', 'image/png') data = source.get('data', '') converted_parts.append( - { - 'type': 'image_url', - 'image_url': { - 'url': f'data:{media_type};base64,{data}', + _copy_cache_control( + content_block, + { + 'type': 'image_url', + 'image_url': { + 'url': f'data:{media_type};base64,{data}', + }, }, - } + ) ) elif source.get('type') == 'url': converted_parts.append( - { - 'type': 'image_url', - 'image_url': { - 'url': source.get('url', ''), + _copy_cache_control( + content_block, + { + 'type': 'image_url', + 'image_url': { + 'url': source.get('url', ''), + }, }, - } + ) ) elif content_type == 'document': # Documents have no direct OpenAI equivalent; @@ -254,7 +299,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: converted_parts.append({'type': 'text', 'text': search_text}) # Flatten to string when only text parts are present - if all(part.get('type') == 'text' for part in converted_parts): + if all(part.get('type') == 'text' for part in converted_parts) and not _has_cache_control( + converted_parts + ): tool_content = '\n'.join(part.get('text', '') for part in converted_parts) elif converted_parts: tool_content = converted_parts @@ -287,21 +334,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: # Assistant message with tool calls msg_dict = {'role': role} if openai_content: - # If there's only text, flatten it - if len(openai_content) == 1 and openai_content[0]['type'] == 'text': - msg_dict['content'] = openai_content[0]['text'] - else: - msg_dict['content'] = openai_content + msg_dict['content'] = _finalize_openai_content(openai_content) else: msg_dict['content'] = '' msg_dict['tool_calls'] = tool_calls messages.append(msg_dict) elif openai_content: - # If there's only a single text block, flatten it to a string - if len(openai_content) == 1 and openai_content[0]['type'] == 'text': - messages.append({'role': role, 'content': openai_content[0]['text']}) - else: - messages.append({'role': role, 'content': openai_content}) + messages.append({'role': role, 'content': _finalize_openai_content(openai_content)}) else: messages.append({'role': role, 'content': str(content) if content else ''}) @@ -312,7 +351,7 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: openai_payload['max_tokens'] = anthropic_payload['max_tokens'] # Common parameters - for param in ('temperature', 'top_p', 'stop_sequences', 'stream'): + for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'): if param in anthropic_payload: if param == 'stop_sequences': openai_payload['stop'] = anthropic_payload[param] @@ -324,14 +363,17 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: openai_tools = [] for tool in anthropic_payload['tools']: openai_tools.append( - { - 'type': 'function', - 'function': { - 'name': tool.get('name', ''), - 'description': tool.get('description', ''), - 'parameters': tool.get('input_schema', {}), + _copy_cache_control( + tool, + { + 'type': 'function', + 'function': { + 'name': tool.get('name', ''), + 'description': tool.get('description', ''), + 'parameters': tool.get('input_schema', {}), + }, }, - } + ) ) openai_payload['tools'] = openai_tools @@ -382,7 +424,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') content.append({'type': 'text', 'text': message_content}) # Tool calls -> tool_use blocks - tool_calls = message.get('tool_calls', []) + tool_calls = message.get('tool_calls') or [] for tool_call in tool_calls: function = tool_call.get('function', {}) try: @@ -404,6 +446,10 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') 'input_tokens': openai_usage.get('prompt_tokens', 0), 'output_tokens': openai_usage.get('completion_tokens', 0), } + if 'cache_creation_input_tokens' in openai_usage: + usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens'] + if 'cache_read_input_tokens' in openai_usage: + usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens'] return { 'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'), @@ -703,4 +749,3 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Emit message_stop yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n'.encode() - From ed4cb358a06fc6962b378f20ef846fd2b0af90bc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:39:11 +0200 Subject: [PATCH 055/346] refac --- src/lib/components/layout/Sidebar.svelte | 23 ++++++++++++-- .../components/layout/Sidebar/ChatItem.svelte | 16 +++++++--- .../components/layout/Sidebar/ChatMenu.svelte | 24 +++++++------- .../layout/Sidebar/RecursiveFolder.svelte | 5 +++ src/routes/s/[id]/+page.svelte | 31 ++++++++++++------- 5 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 2f91772191a..ea8ef65f12d 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -35,6 +35,8 @@ const i18n = getContext('i18n'); + $: canImportChats = $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true); + import { getChatList, getAllTags, @@ -372,6 +374,11 @@ }; const importChatHandler = async (items, pinned = false, folderId = null) => { + if (!canImportChats) { + toast.error($i18n.t('Access prohibited')); + return; + } + console.log('importChatHandler', items, pinned, folderId); for (const item of items) { console.log(item); @@ -1401,6 +1408,11 @@ return null; }); if (!chat && item) { + if (!canImportChats) { + toast.error($i18n.t('Access prohibited')); + return; + } + chat = await importChats(localStorage.token, [ { chat: item.chat, @@ -1409,9 +1421,9 @@ folder_id: null, created_at: item?.created_at ?? null, updated_at: item?.updated_at ?? null - } - ]); - } + } + ]); + } if (chat) { console.log(chat); @@ -1467,6 +1479,11 @@ return null; }); if (!chat && item) { + if (!canImportChats) { + toast.error($i18n.t('Access prohibited')); + return; + } + chat = await importChats(localStorage.token, [ { chat: item.chat, diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 498dffbd9f1..4b60fa7c81a 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -9,10 +9,6 @@ import { toast } from 'svelte-sonner'; import { goto, invalidate, invalidateAll } from '$app/navigation'; import { onMount, getContext, createEventDispatcher, tick } from 'svelte'; - const i18n = getContext('i18n'); - - const dispatch = createEventDispatcher(); - import { archiveChatById, cloneChatById, @@ -35,7 +31,8 @@ currentChatPage, tags, selectedFolder, - activeChatIds + activeChatIds, + user } from '$lib/stores'; import ChatMenu from './ChatMenu.svelte'; @@ -53,6 +50,10 @@ import { generateTitle } from '$lib/apis'; import { createMessagesList } from '$lib/utils'; + const i18n = getContext('i18n'); + + const dispatch = createEventDispatcher(); + export let className = ''; export let id; @@ -143,6 +144,11 @@ }; const cloneChatHandler = async (id) => { + if (!($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))) { + toast.error($i18n.t('Access prohibited')); + return; + } + const res = await cloneChatById( localStorage.token, id, diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 1bc932e4303..79ea2d0db93 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -387,17 +387,19 @@ {/if} - + {#if $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)} + + {/if} {#if chatId && $folders.length > 0} { + if (!($sessionUser?.role === 'admin' || ($sessionUser?.permissions?.chat?.import ?? true))) { + toast.error($i18n.t('Access prohibited')); + return; + } + if (!chat) return; const res = await cloneSharedChatById(localStorage.token, chat.id).catch((error) => { @@ -197,18 +202,20 @@
-
-
- + {#if $sessionUser?.role === 'admin' || ($sessionUser?.permissions?.chat?.import ?? true)} +
+
+ +
-
+ {/if}
{/if} From 7d1f9415807a47e0da4f862327e9802a3b839753 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:40:04 +0200 Subject: [PATCH 056/346] refac --- src/lib/components/channel/Messages.svelte | 1 + .../components/channel/Messages/Message.svelte | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lib/components/channel/Messages.svelte b/src/lib/components/channel/Messages.svelte index f15a37be4b3..d3700ec7111 100644 --- a/src/lib/components/channel/Messages.svelte +++ b/src/lib/components/channel/Messages.svelte @@ -128,6 +128,7 @@ {message} {channel} {thread} + {id} replyToMessage={replyToMessage?.id === message.id} disabled={!channel?.write_access || message?.temp_id} pending={!!message?.temp_id} diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index a853a0e7739..13cf310d65f 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -47,6 +47,7 @@ export let showUserProfile = true; export let thread = false; + export let id = null; export let replyToMessage = false; export let disabled = false; @@ -64,6 +65,12 @@ let edit = false; let editedContent = null; let showDeleteConfirmDialog = false; + $: renderedMessageId = message ? (id ? `${id}-${message.id}` : message.id) : null; + $: replyToMessageId = message?.reply_to_message + ? id + ? `${id}-${message.reply_to_message.id}` + : message.reply_to_message.id + : null; // Swipe-to-reply state let swipeStartX = 0; @@ -177,7 +184,7 @@ {/if}
{ const messageElement = document.getElementById( - `message-${message.reply_to_message.id}` + `message-${replyToMessageId}` ); if (messageElement) { messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -354,7 +361,7 @@
@@ -365,7 +372,7 @@
@@ -525,7 +532,7 @@ {:else} Date: Tue, 16 Jun 2026 18:40:57 -0400 Subject: [PATCH 057/346] fix(ui): enforce allow_users permission in share chat modal (#25915) --- src/lib/components/chat/ShareChatModal.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte index 8d67e47d974..7802b8189cf 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -159,6 +159,8 @@ bind:accessGrants accessRoles={['read']} sharePublic={$user?.permissions?.sharing?.public_chats || $user?.role === 'admin'} + shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || + $user?.role === 'admin'} onChange={saveAccessGrants} />
From bb2663aad1a38c98a4c0843f6a5c5e7d910a48af Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:41:18 -0400 Subject: [PATCH 058/346] fix(ui): gate all chat export formats by chat.export permission (#25914) --- src/lib/components/layout/Navbar/Menu.svelte | 62 ++++++++++--------- .../components/layout/Sidebar/ChatMenu.svelte | 62 +++++++++---------- 2 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 36357fd539f..7f6a2e63552 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -379,17 +379,18 @@ {/if} - - -
{$i18n.t('Download')}
- - {#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)} - {/if} - - -
+ + + + + {/if} + - {#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)} - {/if} - + - - + + + {/if}
{/if}
+ +
+
+
+ {$i18n.t('User Webhooks')} +
+ +
+ {#if defaultPermissions?.features?.user_webhooks && !permissions.features.user_webhooks} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +

diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index 7f88f1dee76..fb826dbe396 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -227,7 +227,7 @@
- {#if $config?.features?.enable_user_webhooks} + {#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.user_webhooks ?? false))}
{$i18n.t('Notification Webhook')}
diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index af52a04bf01..8b9813bddcf 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -66,7 +66,8 @@ export const DEFAULT_PERMISSIONS = { code_interpreter: true, memories: true, automations: false, - calendar: true + calendar: true, + user_webhooks: false }, settings: { interface: true From e8d55c0a8beac9de0b2a0fe90f0bc0f9b64c1c1f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 02:36:20 +0200 Subject: [PATCH 063/346] refac --- src/lib/components/workspace/Knowledge.svelte | 2 +- src/lib/components/workspace/Prompts.svelte | 2 +- src/lib/components/workspace/Skills.svelte | 2 +- src/routes/(app)/automations/+page.svelte | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index b4c34889776..9f5b7e439e7 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -56,7 +56,7 @@ clearTimeout(searchDebounceTimer); }); - $: if (viewOption !== undefined) { + $: if (loaded && viewOption !== undefined) { init(); } diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index f9deed219c9..5ce7408cd8c 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -72,7 +72,7 @@ }; // Immediate response to page/filter changes - $: if (page && selectedTag !== undefined && viewOption !== undefined) { + $: if (loaded && page && selectedTag !== undefined && viewOption !== undefined) { getPromptList(); } diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte index ffb9b534af2..99820459470 100644 --- a/src/lib/components/workspace/Skills.svelte +++ b/src/lib/components/workspace/Skills.svelte @@ -91,7 +91,7 @@ }; // Immediate response to page/filter changes - $: if (page && viewOption !== undefined) { + $: if (loaded && page && viewOption !== undefined) { loadSkillItems(); } diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 7ef8246eb46..4985a0bf7f9 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -199,8 +199,6 @@ } loaded = true; - // Explicit initial fetch — reactive blocks will handle subsequent changes - await getAutomationList(); return () => { clearTimeout(searchDebounceTimer); From 5cdcdbaeec9fc8156721c38c33ec37956962871c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 02:52:35 +0200 Subject: [PATCH 064/346] refac --- backend/open_webui/config.py | 2709 +++------ backend/open_webui/internal/config.py | 265 - backend/open_webui/main.py | 1206 +--- ...c63645b8_reshape_config_to_per_key_rows.py | 580 ++ backend/open_webui/models/config.py | 177 + backend/open_webui/retrieval/utils.py | 148 +- .../open_webui/retrieval/web/perplexity.py | 4 +- .../retrieval/web/perplexity_search.py | 8 +- backend/open_webui/retrieval/web/utils.py | 39 +- backend/open_webui/routers/audio.py | 268 +- backend/open_webui/routers/auths.py | 461 +- backend/open_webui/routers/automations.py | 10 +- backend/open_webui/routers/calendar.py | 15 +- backend/open_webui/routers/channels.py | 15 +- backend/open_webui/routers/chats.py | 15 +- backend/open_webui/routers/configs.py | 190 +- backend/open_webui/routers/evaluations.py | 28 +- backend/open_webui/routers/files.py | 14 +- backend/open_webui/routers/folders.py | 10 +- backend/open_webui/routers/images.py | 389 +- backend/open_webui/routers/knowledge.py | 9 +- backend/open_webui/routers/memories.py | 104 +- backend/open_webui/routers/models.py | 17 +- backend/open_webui/routers/notes.py | 25 +- backend/open_webui/routers/ollama.py | 208 +- backend/open_webui/routers/openai.py | 179 +- backend/open_webui/routers/pipelines.py | 37 +- backend/open_webui/routers/prompts.py | 11 +- backend/open_webui/routers/retrieval.py | 1577 ++--- backend/open_webui/routers/scim.py | 7 - backend/open_webui/routers/skills.py | 11 +- backend/open_webui/routers/tasks.py | 174 +- backend/open_webui/routers/terminals.py | 7 +- backend/open_webui/routers/tools.py | 21 +- backend/open_webui/routers/users.py | 29 +- backend/open_webui/routers/utils.py | 17 +- backend/open_webui/tools/builtin.py | 22 +- .../utils/access_control/__init__.py | 2 +- backend/open_webui/utils/asgi_middleware.py | 3 +- backend/open_webui/utils/auth.py | 13 +- backend/open_webui/utils/automations.py | 18 +- backend/open_webui/utils/middleware.py | 83 +- backend/open_webui/utils/models.py | 22 +- backend/open_webui/utils/oauth.py | 229 +- backend/open_webui/utils/tools.py | 37 +- src/lib/apis/auths/index.ts | 55 + src/lib/components/admin/Settings.svelte | 36 + .../admin/Settings/Authentication.svelte | 776 +++ .../components/admin/Settings/General.svelte | 440 +- .../admin/Users/Groups/Permissions.svelte | 4 +- .../components/chat/Settings/Account.svelte | 2 +- src/lib/constants/permissions.ts | 2 +- uv.lock | 5145 +++++++++-------- 53 files changed, 7874 insertions(+), 7999 deletions(-) delete mode 100644 backend/open_webui/internal/config.py create mode 100644 backend/open_webui/migrations/versions/3ff2c63645b8_reshape_config_to_per_key_rows.py create mode 100644 backend/open_webui/models/config.py create mode 100644 src/lib/components/admin/Settings/Authentication.svelte diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index d528f5ad39a..72a71021f91 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -34,70 +34,15 @@ WEBUI_NAME, log, ) -from open_webui.internal.config import ( - STATE as _state, -) -from open_webui.internal.config import ( - AppConfig, - ConfigVar, -) - -# ── Persistent configuration layer ────────────────────────────────────────── -from open_webui.internal.config import ( # noqa: F401 - ConfigTable as Config, -) -from open_webui.internal.config import ( - _all_configs as PERSISTENT_CONFIG_REGISTRY, -) -from open_webui.internal.config import ( - initialize as _initialize_config, -) - +from open_webui.models.config import Config -def get_config(): - return _state.snapshot - -def save_to_db(data): - _state.persist(data) - - -async def async_save_to_db(data): - await _state.persist_async(data) - - -def save_config(config): - try: - _state.persist(config) - for s in PERSISTENT_CONFIG_REGISTRY: - s.refresh() - except Exception: - log.exception('Failed to save config') - return False - return True - - -async def async_save_config(config): - try: - await _state.persist_async(config) - for s in PERSISTENT_CONFIG_REGISTRY: - s.refresh() - except Exception: - log.exception('Failed to save config') - return False - return True - - -def reset_config(): - _state.clear() +async def seed_registered_defaults(): + await Config.seed_defaults(DEFAULT_CONFIG) async def async_reset_config(): - await _state.clear_async() - - -def get_config_value(config_path: str): - return _state.read(config_path) + await Config.clear() class EndpointFilter(logging.Filter): @@ -132,22 +77,15 @@ def run_migrations(): run_migrations() -# Migrate legacy config.json → database on first run -if os.path.exists(f'{DATA_DIR}/config.json'): +async def import_legacy_config_json(): + """Migrate legacy config.json → database on first run.""" + if not os.path.exists(f'{DATA_DIR}/config.json'): + return with open(f'{DATA_DIR}/config.json', 'r') as _f: - save_to_db(json.load(_f)) + await Config.upsert(json.load(_f)) os.rename(f'{DATA_DIR}/config.json', f'{DATA_DIR}/old_config.json') -ENABLE_PERSISTENT_CONFIG = os.getenv('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true' -ENABLE_OAUTH_PERSISTENT_CONFIG = os.getenv('ENABLE_OAUTH_PERSISTENT_CONFIG', 'False').lower() == 'true' - -# Bootstrap the persistent config subsystem -CONFIG_DATA = _initialize_config( - enable_persistent=ENABLE_PERSISTENT_CONFIG, - enable_oauth_persistent=ENABLE_OAUTH_PERSISTENT_CONFIG, -) - #################################### # Static DIR #################################### @@ -278,21 +216,13 @@ def run_migrations(): # DIRECT CONNECTIONS #################################### -ENABLE_DIRECT_CONNECTIONS = ConfigVar( - 'ENABLE_DIRECT_CONNECTIONS', - 'direct.enable', - os.getenv('ENABLE_DIRECT_CONNECTIONS', 'False').lower() == 'true', -) +ENABLE_DIRECT_CONNECTIONS = os.getenv('ENABLE_DIRECT_CONNECTIONS', 'False').lower() == 'true' #################################### # OLLAMA_BASE_URL #################################### -ENABLE_OLLAMA_API = ConfigVar( - 'ENABLE_OLLAMA_API', - 'ollama.enable', - os.getenv('ENABLE_OLLAMA_API', 'True').lower() == 'true', -) +ENABLE_OLLAMA_API = os.getenv('ENABLE_OLLAMA_API', 'True').lower() == 'true' OLLAMA_API_BASE_URL = os.getenv('OLLAMA_API_BASE_URL', 'http://localhost:11434/api') @@ -355,24 +285,16 @@ def reachable(host: str, port: int) -> bool: OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != '' else OLLAMA_BASE_URL OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(';')] -OLLAMA_BASE_URLS = ConfigVar('OLLAMA_BASE_URLS', 'ollama.base_urls', OLLAMA_BASE_URLS) +OLLAMA_BASE_URLS = OLLAMA_BASE_URLS -OLLAMA_API_CONFIGS = ConfigVar( - 'OLLAMA_API_CONFIGS', - 'ollama.api_configs', - {}, -) +OLLAMA_API_CONFIGS = {} #################################### # OPENAI_API #################################### -ENABLE_OPENAI_API = ConfigVar( - 'ENABLE_OPENAI_API', - 'openai.enable', - os.getenv('ENABLE_OPENAI_API', 'True').lower() == 'true', -) +ENABLE_OPENAI_API = os.getenv('ENABLE_OPENAI_API', 'True').lower() == 'true' OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '') @@ -392,7 +314,7 @@ def reachable(host: str, port: int) -> bool: OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != '' else OPENAI_API_KEY OPENAI_API_KEYS = [url.strip() for url in OPENAI_API_KEYS.split(';')] -OPENAI_API_KEYS = ConfigVar('OPENAI_API_KEYS', 'openai.api_keys', OPENAI_API_KEYS) +OPENAI_API_KEYS = OPENAI_API_KEYS OPENAI_API_BASE_URLS = os.getenv('OPENAI_API_BASE_URLS', '') OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS if OPENAI_API_BASE_URLS != '' else OPENAI_API_BASE_URL @@ -400,18 +322,14 @@ def reachable(host: str, port: int) -> bool: OPENAI_API_BASE_URLS = [ url.strip() if url != '' else 'https://api.openai.com/v1' for url in OPENAI_API_BASE_URLS.split(';') ] -OPENAI_API_BASE_URLS = ConfigVar('OPENAI_API_BASE_URLS', 'openai.api_base_urls', OPENAI_API_BASE_URLS) +OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS -OPENAI_API_CONFIGS = ConfigVar( - 'OPENAI_API_CONFIGS', - 'openai.api_configs', - {}, -) +OPENAI_API_CONFIGS = {} # Get the actual OpenAI API key based on the base URL OPENAI_API_KEY = '' try: - OPENAI_API_KEY = OPENAI_API_KEYS.value[OPENAI_API_BASE_URLS.value.index('https://api.openai.com/v1')] + OPENAI_API_KEY = OPENAI_API_KEYS[OPENAI_API_BASE_URLS.index('https://api.openai.com/v1')] except Exception: pass OPENAI_API_BASE_URL = 'https://api.openai.com/v1' @@ -421,11 +339,7 @@ def reachable(host: str, port: int) -> bool: # MODELS #################################### -ENABLE_BASE_MODELS_CACHE = ConfigVar( - 'ENABLE_BASE_MODELS_CACHE', - 'models.base_models_cache', - os.getenv('ENABLE_BASE_MODELS_CACHE', 'False').lower() == 'true', -) +ENABLE_BASE_MODELS_CACHE = os.getenv('ENABLE_BASE_MODELS_CACHE', 'False').lower() == 'true' #################################### @@ -439,17 +353,9 @@ def reachable(host: str, port: int) -> bool: tool_server_connections = [] -TOOL_SERVER_CONNECTIONS = ConfigVar( - 'TOOL_SERVER_CONNECTIONS', - 'tool_server.connections', - tool_server_connections, -) +TOOL_SERVER_CONNECTIONS = tool_server_connections -OAUTH_CLIENT_TIMEOUT = ConfigVar( - 'OAUTH_CLIENT_TIMEOUT', - 'oauth.client.timeout', - os.getenv('OAUTH_CLIENT_TIMEOUT', ''), -) +OAUTH_CLIENT_TIMEOUT = os.getenv('OAUTH_CLIENT_TIMEOUT', '') #################################### # TERMINAL_SERVER @@ -457,11 +363,7 @@ def reachable(host: str, port: int) -> bool: terminal_server_connections = json.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]')) -TERMINAL_SERVER_CONNECTIONS = ConfigVar( - 'TERMINAL_SERVER_CONNECTIONS', - 'terminal_server.connections', - terminal_server_connections, -) +TERMINAL_SERVER_CONNECTIONS = terminal_server_connections try: TERMINAL_PROXY_HEADERS = json.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}')) @@ -472,117 +374,39 @@ def reachable(host: str, port: int) -> bool: # Code Interpreter #################################### -ENABLE_CODE_EXECUTION = ConfigVar( - 'ENABLE_CODE_EXECUTION', - 'code_execution.enable', - os.getenv('ENABLE_CODE_EXECUTION', 'True').lower() == 'true', -) +ENABLE_CODE_EXECUTION = os.getenv('ENABLE_CODE_EXECUTION', 'True').lower() == 'true' -CODE_EXECUTION_ENGINE = ConfigVar( - 'CODE_EXECUTION_ENGINE', - 'code_execution.engine', - os.getenv('CODE_EXECUTION_ENGINE', 'pyodide'), -) +CODE_EXECUTION_ENGINE = os.getenv('CODE_EXECUTION_ENGINE', 'pyodide') -CODE_EXECUTION_JUPYTER_URL = ConfigVar( - 'CODE_EXECUTION_JUPYTER_URL', - 'code_execution.jupyter.url', - os.getenv('CODE_EXECUTION_JUPYTER_URL', ''), -) +CODE_EXECUTION_JUPYTER_URL = os.getenv('CODE_EXECUTION_JUPYTER_URL', '') -CODE_EXECUTION_JUPYTER_AUTH = ConfigVar( - 'CODE_EXECUTION_JUPYTER_AUTH', - 'code_execution.jupyter.auth', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH', ''), -) +CODE_EXECUTION_JUPYTER_AUTH = os.getenv('CODE_EXECUTION_JUPYTER_AUTH', '') -CODE_EXECUTION_JUPYTER_AUTH_TOKEN = ConfigVar( - 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN', - 'code_execution.jupyter.auth_token', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', ''), -) +CODE_EXECUTION_JUPYTER_AUTH_TOKEN = os.getenv('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', '') -CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = ConfigVar( - 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', - 'code_execution.jupyter.auth_password', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', ''), -) +CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = os.getenv('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', '') -CODE_EXECUTION_JUPYTER_TIMEOUT = ConfigVar( - 'CODE_EXECUTION_JUPYTER_TIMEOUT', - 'code_execution.jupyter.timeout', - int(os.getenv('CODE_EXECUTION_JUPYTER_TIMEOUT', '60')), -) +CODE_EXECUTION_JUPYTER_TIMEOUT = int(os.getenv('CODE_EXECUTION_JUPYTER_TIMEOUT', '60')) -ENABLE_CODE_INTERPRETER = ConfigVar( - 'ENABLE_CODE_INTERPRETER', - 'code_interpreter.enable', - os.getenv('ENABLE_CODE_INTERPRETER', 'True').lower() == 'true', -) +ENABLE_CODE_INTERPRETER = os.getenv('ENABLE_CODE_INTERPRETER', 'True').lower() == 'true' -ENABLE_MEMORIES = ConfigVar( - 'ENABLE_MEMORIES', - 'memories.enable', - os.getenv('ENABLE_MEMORIES', 'True').lower() == 'true', -) +ENABLE_MEMORIES = os.getenv('ENABLE_MEMORIES', 'True').lower() == 'true' -CODE_INTERPRETER_ENGINE = ConfigVar( - 'CODE_INTERPRETER_ENGINE', - 'code_interpreter.engine', - os.getenv('CODE_INTERPRETER_ENGINE', 'pyodide'), -) +CODE_INTERPRETER_ENGINE = os.getenv('CODE_INTERPRETER_ENGINE', 'pyodide') -CODE_INTERPRETER_PROMPT_TEMPLATE = ConfigVar( - 'CODE_INTERPRETER_PROMPT_TEMPLATE', - 'code_interpreter.prompt_template', - os.getenv('CODE_INTERPRETER_PROMPT_TEMPLATE', ''), -) +CODE_INTERPRETER_PROMPT_TEMPLATE = os.getenv('CODE_INTERPRETER_PROMPT_TEMPLATE', '') -CODE_INTERPRETER_JUPYTER_URL = ConfigVar( - 'CODE_INTERPRETER_JUPYTER_URL', - 'code_interpreter.jupyter.url', - os.getenv('CODE_INTERPRETER_JUPYTER_URL', os.getenv('CODE_EXECUTION_JUPYTER_URL', '')), -) +CODE_INTERPRETER_JUPYTER_URL = os.getenv('CODE_INTERPRETER_JUPYTER_URL', os.getenv('CODE_EXECUTION_JUPYTER_URL', '')) -CODE_INTERPRETER_JUPYTER_AUTH = ConfigVar( - 'CODE_INTERPRETER_JUPYTER_AUTH', - 'code_interpreter.jupyter.auth', - os.getenv( - 'CODE_INTERPRETER_JUPYTER_AUTH', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH', ''), - ), -) +CODE_INTERPRETER_JUPYTER_AUTH = os.getenv( 'CODE_INTERPRETER_JUPYTER_AUTH', os.getenv('CODE_EXECUTION_JUPYTER_AUTH', ''), ) -CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = ConfigVar( - 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN', - 'code_interpreter.jupyter.auth_token', - os.getenv( - 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', ''), - ), -) +CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = os.getenv( 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN', os.getenv('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', ''), ) -CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ConfigVar( - 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD', - 'code_interpreter.jupyter.auth_password', - os.getenv( - 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD', - os.getenv('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', ''), - ), -) +CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = os.getenv( 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD', os.getenv('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', ''), ) -CODE_INTERPRETER_JUPYTER_TIMEOUT = ConfigVar( - 'CODE_INTERPRETER_JUPYTER_TIMEOUT', - 'code_interpreter.jupyter.timeout', - int( - os.getenv( - 'CODE_INTERPRETER_JUPYTER_TIMEOUT', - os.getenv('CODE_EXECUTION_JUPYTER_TIMEOUT', '60'), - ) - ), -) +CODE_INTERPRETER_JUPYTER_TIMEOUT = int( os.getenv( 'CODE_INTERPRETER_JUPYTER_TIMEOUT', os.getenv('CODE_EXECUTION_JUPYTER_TIMEOUT', '60'), ) ) CODE_INTERPRETER_BLOCKED_MODULES = [ library.strip() for library in os.getenv('CODE_INTERPRETER_BLOCKED_MODULES', '').split(',') if library.strip() @@ -961,29 +785,13 @@ def reachable(host: str, port: int) -> bool: # If configured, Google Drive will be available as an upload option. -ENABLE_GOOGLE_DRIVE_INTEGRATION = ConfigVar( - 'ENABLE_GOOGLE_DRIVE_INTEGRATION', - 'google_drive.enable', - os.getenv('ENABLE_GOOGLE_DRIVE_INTEGRATION', 'False').lower() == 'true', -) +ENABLE_GOOGLE_DRIVE_INTEGRATION = os.getenv('ENABLE_GOOGLE_DRIVE_INTEGRATION', 'False').lower() == 'true' -GOOGLE_DRIVE_CLIENT_ID = ConfigVar( - 'GOOGLE_DRIVE_CLIENT_ID', - 'google_drive.client_id', - os.getenv('GOOGLE_DRIVE_CLIENT_ID', ''), -) +GOOGLE_DRIVE_CLIENT_ID = os.getenv('GOOGLE_DRIVE_CLIENT_ID', '') -GOOGLE_DRIVE_API_KEY = ConfigVar( - 'GOOGLE_DRIVE_API_KEY', - 'google_drive.api_key', - os.getenv('GOOGLE_DRIVE_API_KEY', ''), -) +GOOGLE_DRIVE_API_KEY = os.getenv('GOOGLE_DRIVE_API_KEY', '') -ENABLE_ONEDRIVE_INTEGRATION = ConfigVar( - 'ENABLE_ONEDRIVE_INTEGRATION', - 'onedrive.enable', - os.getenv('ENABLE_ONEDRIVE_INTEGRATION', 'False').lower() == 'true', -) +ENABLE_ONEDRIVE_INTEGRATION = os.getenv('ENABLE_ONEDRIVE_INTEGRATION', 'False').lower() == 'true' ONEDRIVE_CLIENT_ID = os.getenv('ONEDRIVE_CLIENT_ID', '') @@ -997,114 +805,42 @@ def reachable(host: str, port: int) -> bool: ONEDRIVE_CLIENT_ID_BUSINESS ) -ONEDRIVE_SHAREPOINT_URL = ConfigVar( - 'ONEDRIVE_SHAREPOINT_URL', - 'onedrive.sharepoint_url', - os.getenv('ONEDRIVE_SHAREPOINT_URL', ''), -) +ONEDRIVE_SHAREPOINT_URL = os.getenv('ONEDRIVE_SHAREPOINT_URL', '') -ONEDRIVE_SHAREPOINT_TENANT_ID = ConfigVar( - 'ONEDRIVE_SHAREPOINT_TENANT_ID', - 'onedrive.sharepoint_tenant_id', - os.getenv('ONEDRIVE_SHAREPOINT_TENANT_ID', ''), -) +ONEDRIVE_SHAREPOINT_TENANT_ID = os.getenv('ONEDRIVE_SHAREPOINT_TENANT_ID', '') # RAG Content Extraction -CONTENT_EXTRACTION_ENGINE = ConfigVar( - 'CONTENT_EXTRACTION_ENGINE', - 'rag.CONTENT_EXTRACTION_ENGINE', - os.getenv('CONTENT_EXTRACTION_ENGINE', '').lower(), -) +CONTENT_EXTRACTION_ENGINE = os.getenv('CONTENT_EXTRACTION_ENGINE', '').lower() -DATALAB_MARKER_API_KEY = ConfigVar( - 'DATALAB_MARKER_API_KEY', - 'rag.datalab_marker_api_key', - os.getenv('DATALAB_MARKER_API_KEY', ''), -) +DATALAB_MARKER_API_KEY = os.getenv('DATALAB_MARKER_API_KEY', '') -DATALAB_MARKER_API_BASE_URL = ConfigVar( - 'DATALAB_MARKER_API_BASE_URL', - 'rag.datalab_marker_api_base_url', - os.getenv('DATALAB_MARKER_API_BASE_URL', ''), -) +DATALAB_MARKER_API_BASE_URL = os.getenv('DATALAB_MARKER_API_BASE_URL', '') -DATALAB_MARKER_ADDITIONAL_CONFIG = ConfigVar( - 'DATALAB_MARKER_ADDITIONAL_CONFIG', - 'rag.datalab_marker_additional_config', - os.getenv('DATALAB_MARKER_ADDITIONAL_CONFIG', ''), -) +DATALAB_MARKER_ADDITIONAL_CONFIG = os.getenv('DATALAB_MARKER_ADDITIONAL_CONFIG', '') -DATALAB_MARKER_USE_LLM = ConfigVar( - 'DATALAB_MARKER_USE_LLM', - 'rag.DATALAB_MARKER_USE_LLM', - os.getenv('DATALAB_MARKER_USE_LLM', 'false').lower() == 'true', -) +DATALAB_MARKER_USE_LLM = os.getenv('DATALAB_MARKER_USE_LLM', 'false').lower() == 'true' -DATALAB_MARKER_SKIP_CACHE = ConfigVar( - 'DATALAB_MARKER_SKIP_CACHE', - 'rag.datalab_marker_skip_cache', - os.getenv('DATALAB_MARKER_SKIP_CACHE', 'false').lower() == 'true', -) +DATALAB_MARKER_SKIP_CACHE = os.getenv('DATALAB_MARKER_SKIP_CACHE', 'false').lower() == 'true' -DATALAB_MARKER_FORCE_OCR = ConfigVar( - 'DATALAB_MARKER_FORCE_OCR', - 'rag.datalab_marker_force_ocr', - os.getenv('DATALAB_MARKER_FORCE_OCR', 'false').lower() == 'true', -) +DATALAB_MARKER_FORCE_OCR = os.getenv('DATALAB_MARKER_FORCE_OCR', 'false').lower() == 'true' -DATALAB_MARKER_PAGINATE = ConfigVar( - 'DATALAB_MARKER_PAGINATE', - 'rag.datalab_marker_paginate', - os.getenv('DATALAB_MARKER_PAGINATE', 'false').lower() == 'true', -) +DATALAB_MARKER_PAGINATE = os.getenv('DATALAB_MARKER_PAGINATE', 'false').lower() == 'true' -DATALAB_MARKER_STRIP_EXISTING_OCR = ConfigVar( - 'DATALAB_MARKER_STRIP_EXISTING_OCR', - 'rag.datalab_marker_strip_existing_ocr', - os.getenv('DATALAB_MARKER_STRIP_EXISTING_OCR', 'false').lower() == 'true', -) +DATALAB_MARKER_STRIP_EXISTING_OCR = os.getenv('DATALAB_MARKER_STRIP_EXISTING_OCR', 'false').lower() == 'true' -DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ConfigVar( - 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', - 'rag.datalab_marker_disable_image_extraction', - os.getenv('DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', 'false').lower() == 'true', -) +DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = os.getenv('DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', 'false').lower() == 'true' -DATALAB_MARKER_FORMAT_LINES = ConfigVar( - 'DATALAB_MARKER_FORMAT_LINES', - 'rag.datalab_marker_format_lines', - os.getenv('DATALAB_MARKER_FORMAT_LINES', 'false').lower() == 'true', -) +DATALAB_MARKER_FORMAT_LINES = os.getenv('DATALAB_MARKER_FORMAT_LINES', 'false').lower() == 'true' -DATALAB_MARKER_OUTPUT_FORMAT = ConfigVar( - 'DATALAB_MARKER_OUTPUT_FORMAT', - 'rag.datalab_marker_output_format', - os.getenv('DATALAB_MARKER_OUTPUT_FORMAT', 'markdown'), -) +DATALAB_MARKER_OUTPUT_FORMAT = os.getenv('DATALAB_MARKER_OUTPUT_FORMAT', 'markdown') -MINERU_API_MODE = ConfigVar( - 'MINERU_API_MODE', - 'rag.mineru_api_mode', - os.getenv('MINERU_API_MODE', 'local'), # "local" or "cloud" -) +MINERU_API_MODE = os.getenv('MINERU_API_MODE', 'local') -MINERU_API_URL = ConfigVar( - 'MINERU_API_URL', - 'rag.mineru_api_url', - os.getenv('MINERU_API_URL', 'http://localhost:8000'), -) +MINERU_API_URL = os.getenv('MINERU_API_URL', 'http://localhost:8000') -MINERU_API_TIMEOUT = ConfigVar( - 'MINERU_API_TIMEOUT', - 'rag.mineru_api_timeout', - os.getenv('MINERU_API_TIMEOUT', '300'), -) +MINERU_API_TIMEOUT = os.getenv('MINERU_API_TIMEOUT', '300') -MINERU_API_KEY = ConfigVar( - 'MINERU_API_KEY', - 'rag.mineru_api_key', - os.getenv('MINERU_API_KEY', ''), -) +MINERU_API_KEY = os.getenv('MINERU_API_KEY', '') mineru_params = os.getenv('MINERU_PARAMS', '') try: @@ -1112,47 +848,19 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: mineru_params = {} -MINERU_PARAMS = ConfigVar( - 'MINERU_PARAMS', - 'rag.mineru_params', - mineru_params, -) +MINERU_PARAMS = mineru_params -MINERU_FILE_EXTENSIONS = ConfigVar( - 'MINERU_FILE_EXTENSIONS', - 'rag.mineru_file_extensions', - [ext.strip() for ext in os.getenv('MINERU_FILE_EXTENSIONS', 'pdf').split(',') if ext.strip()], -) +MINERU_FILE_EXTENSIONS = [ext.strip() for ext in os.getenv('MINERU_FILE_EXTENSIONS', 'pdf').split(',') if ext.strip()] -EXTERNAL_DOCUMENT_LOADER_URL = ConfigVar( - 'EXTERNAL_DOCUMENT_LOADER_URL', - 'rag.external_document_loader_url', - os.getenv('EXTERNAL_DOCUMENT_LOADER_URL', ''), -) +EXTERNAL_DOCUMENT_LOADER_URL = os.getenv('EXTERNAL_DOCUMENT_LOADER_URL', '') -EXTERNAL_DOCUMENT_LOADER_API_KEY = ConfigVar( - 'EXTERNAL_DOCUMENT_LOADER_API_KEY', - 'rag.external_document_loader_api_key', - os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY', ''), -) +EXTERNAL_DOCUMENT_LOADER_API_KEY = os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY', '') -TIKA_SERVER_URL = ConfigVar( - 'TIKA_SERVER_URL', - 'rag.tika_server_url', - os.getenv('TIKA_SERVER_URL', 'http://tika:9998'), # Default for sidecar deployment -) +TIKA_SERVER_URL = os.getenv('TIKA_SERVER_URL', 'http://tika:9998') -DOCLING_SERVER_URL = ConfigVar( - 'DOCLING_SERVER_URL', - 'rag.docling_server_url', - os.getenv('DOCLING_SERVER_URL', 'http://docling:5001'), -) +DOCLING_SERVER_URL = os.getenv('DOCLING_SERVER_URL', 'http://docling:5001') -DOCLING_API_KEY = ConfigVar( - 'DOCLING_API_KEY', - 'rag.docling_api_key', - os.getenv('DOCLING_API_KEY', ''), -) +DOCLING_API_KEY = os.getenv('DOCLING_API_KEY', '') docling_params = os.getenv('DOCLING_PARAMS', '') try: @@ -1160,151 +868,55 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: docling_params = {} -DOCLING_PARAMS = ConfigVar( - 'DOCLING_PARAMS', - 'rag.docling_params', - docling_params, -) +DOCLING_PARAMS = docling_params -DOCUMENT_INTELLIGENCE_ENDPOINT = ConfigVar( - 'DOCUMENT_INTELLIGENCE_ENDPOINT', - 'rag.document_intelligence_endpoint', - os.getenv('DOCUMENT_INTELLIGENCE_ENDPOINT', ''), -) +DOCUMENT_INTELLIGENCE_ENDPOINT = os.getenv('DOCUMENT_INTELLIGENCE_ENDPOINT', '') -DOCUMENT_INTELLIGENCE_KEY = ConfigVar( - 'DOCUMENT_INTELLIGENCE_KEY', - 'rag.document_intelligence_key', - os.getenv('DOCUMENT_INTELLIGENCE_KEY', ''), -) +DOCUMENT_INTELLIGENCE_KEY = os.getenv('DOCUMENT_INTELLIGENCE_KEY', '') -DOCUMENT_INTELLIGENCE_MODEL = ConfigVar( - 'DOCUMENT_INTELLIGENCE_MODEL', - 'rag.document_intelligence_model', - os.getenv('DOCUMENT_INTELLIGENCE_MODEL', 'prebuilt-layout'), -) +DOCUMENT_INTELLIGENCE_MODEL = os.getenv('DOCUMENT_INTELLIGENCE_MODEL', 'prebuilt-layout') -MISTRAL_OCR_API_BASE_URL = ConfigVar( - 'MISTRAL_OCR_API_BASE_URL', - 'rag.MISTRAL_OCR_API_BASE_URL', - os.getenv('MISTRAL_OCR_API_BASE_URL', 'https://api.mistral.ai/v1'), -) +MISTRAL_OCR_API_BASE_URL = os.getenv('MISTRAL_OCR_API_BASE_URL', 'https://api.mistral.ai/v1') -MISTRAL_OCR_API_KEY = ConfigVar( - 'MISTRAL_OCR_API_KEY', - 'rag.mistral_ocr_api_key', - os.getenv('MISTRAL_OCR_API_KEY', ''), -) +MISTRAL_OCR_API_KEY = os.getenv('MISTRAL_OCR_API_KEY', '') -PADDLEOCR_VL_BASE_URL = ConfigVar( - 'PADDLEOCR_VL_BASE_URL', - 'rag.paddleocr_vl_base_url', - os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080'), -) +PADDLEOCR_VL_BASE_URL = os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080') -PADDLEOCR_VL_TOKEN = ConfigVar( - 'PADDLEOCR_VL_TOKEN', - 'rag.paddleocr_vl_token', - os.getenv('PADDLEOCR_VL_TOKEN', ''), -) +PADDLEOCR_VL_TOKEN = os.getenv('PADDLEOCR_VL_TOKEN', '') -BYPASS_EMBEDDING_AND_RETRIEVAL = ConfigVar( - 'BYPASS_EMBEDDING_AND_RETRIEVAL', - 'rag.bypass_embedding_and_retrieval', - os.getenv('BYPASS_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true', -) +BYPASS_EMBEDDING_AND_RETRIEVAL = os.getenv('BYPASS_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true' -RAG_TOP_K = ConfigVar('RAG_TOP_K', 'rag.top_k', int(os.getenv('RAG_TOP_K', '3'))) -RAG_TOP_K_RERANKER = ConfigVar( - 'RAG_TOP_K_RERANKER', - 'rag.top_k_reranker', - int(os.getenv('RAG_TOP_K_RERANKER', '3')), -) -RAG_RELEVANCE_THRESHOLD = ConfigVar( - 'RAG_RELEVANCE_THRESHOLD', - 'rag.relevance_threshold', - float(os.getenv('RAG_RELEVANCE_THRESHOLD', '0.0')), -) -RAG_HYBRID_BM25_WEIGHT = ConfigVar( - 'RAG_HYBRID_BM25_WEIGHT', - 'rag.hybrid_bm25_weight', - float(os.getenv('RAG_HYBRID_BM25_WEIGHT', '0.5')), -) +RAG_TOP_K = int(os.getenv('RAG_TOP_K', '3')) +RAG_TOP_K_RERANKER = int(os.getenv('RAG_TOP_K_RERANKER', '3')) +RAG_RELEVANCE_THRESHOLD = float(os.getenv('RAG_RELEVANCE_THRESHOLD', '0.0')) +RAG_HYBRID_BM25_WEIGHT = float(os.getenv('RAG_HYBRID_BM25_WEIGHT', '0.5')) -ENABLE_RAG_HYBRID_SEARCH = ConfigVar( - 'ENABLE_RAG_HYBRID_SEARCH', - 'rag.enable_hybrid_search', - os.getenv('ENABLE_RAG_HYBRID_SEARCH', '').lower() == 'true', -) +ENABLE_RAG_HYBRID_SEARCH = os.getenv('ENABLE_RAG_HYBRID_SEARCH', '').lower() == 'true' -ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = ConfigVar( - 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS', - 'rag.enable_hybrid_search_enriched_texts', - os.getenv('ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS', 'False').lower() == 'true', -) +ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = os.getenv('ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS', 'False').lower() == 'true' -RAG_FULL_CONTEXT = ConfigVar( - 'RAG_FULL_CONTEXT', - 'rag.full_context', - os.getenv('RAG_FULL_CONTEXT', 'False').lower() == 'true', -) +RAG_FULL_CONTEXT = os.getenv('RAG_FULL_CONTEXT', 'False').lower() == 'true' -RAG_FILE_MAX_COUNT = ConfigVar( - 'RAG_FILE_MAX_COUNT', - 'rag.file.max_count', - (int(os.getenv('RAG_FILE_MAX_COUNT')) if os.getenv('RAG_FILE_MAX_COUNT') else None), -) +RAG_FILE_MAX_COUNT = int(os.getenv('RAG_FILE_MAX_COUNT')) if os.getenv('RAG_FILE_MAX_COUNT') else None -RAG_FILE_MAX_SIZE = ConfigVar( - 'RAG_FILE_MAX_SIZE', - 'rag.file.max_size', - (int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None), -) +RAG_FILE_MAX_SIZE = int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None -FILE_IMAGE_COMPRESSION_WIDTH = ConfigVar( - 'FILE_IMAGE_COMPRESSION_WIDTH', - 'file.image_compression_width', - (int(os.getenv('FILE_IMAGE_COMPRESSION_WIDTH')) if os.getenv('FILE_IMAGE_COMPRESSION_WIDTH') else None), -) +FILE_IMAGE_COMPRESSION_WIDTH = int(os.getenv('FILE_IMAGE_COMPRESSION_WIDTH')) if os.getenv('FILE_IMAGE_COMPRESSION_WIDTH') else None -FILE_IMAGE_COMPRESSION_HEIGHT = ConfigVar( - 'FILE_IMAGE_COMPRESSION_HEIGHT', - 'file.image_compression_height', - (int(os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT')) if os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT') else None), -) +FILE_IMAGE_COMPRESSION_HEIGHT = int(os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT')) if os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT') else None -RAG_ALLOWED_FILE_EXTENSIONS = ConfigVar( - 'RAG_ALLOWED_FILE_EXTENSIONS', - 'rag.file.allowed_extensions', - [ext.strip() for ext in os.getenv('RAG_ALLOWED_FILE_EXTENSIONS', '').split(',') if ext.strip()], -) +RAG_ALLOWED_FILE_EXTENSIONS = [ext.strip() for ext in os.getenv('RAG_ALLOWED_FILE_EXTENSIONS', '').split(',') if ext.strip()] -RAG_EMBEDDING_ENGINE = ConfigVar( - 'RAG_EMBEDDING_ENGINE', - 'rag.embedding_engine', - os.getenv('RAG_EMBEDDING_ENGINE', ''), -) +RAG_EMBEDDING_ENGINE = os.getenv('RAG_EMBEDDING_ENGINE', '') -PDF_EXTRACT_IMAGES = ConfigVar( - 'PDF_EXTRACT_IMAGES', - 'rag.pdf_extract_images', - os.getenv('PDF_EXTRACT_IMAGES', 'False').lower() == 'true', -) +PDF_EXTRACT_IMAGES = os.getenv('PDF_EXTRACT_IMAGES', 'False').lower() == 'true' -PDF_LOADER_MODE = ConfigVar( - 'PDF_LOADER_MODE', - 'rag.pdf_loader_mode', - os.getenv('PDF_LOADER_MODE', 'page'), -) +PDF_LOADER_MODE = os.getenv('PDF_LOADER_MODE', 'page') -RAG_EMBEDDING_MODEL = ConfigVar( - 'RAG_EMBEDDING_MODEL', - 'rag.embedding_model', - os.getenv('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2'), -) -log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL.value}') +RAG_EMBEDDING_MODEL = os.getenv('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2') +log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL}') RAG_EMBEDDING_MODEL_AUTO_UPDATE = ( not OFFLINE_MODE and os.getenv('RAG_EMBEDDING_MODEL_AUTO_UPDATE', 'True').lower() == 'true' @@ -1312,23 +924,11 @@ def reachable(host: str, port: int) -> bool: RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = os.getenv('RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' -RAG_EMBEDDING_BATCH_SIZE = ConfigVar( - 'RAG_EMBEDDING_BATCH_SIZE', - 'rag.embedding_batch_size', - int(os.getenv('RAG_EMBEDDING_BATCH_SIZE') or os.getenv('RAG_EMBEDDING_OPENAI_BATCH_SIZE', '1')), -) +RAG_EMBEDDING_BATCH_SIZE = int(os.getenv('RAG_EMBEDDING_BATCH_SIZE') or os.getenv('RAG_EMBEDDING_OPENAI_BATCH_SIZE', '1')) -ENABLE_ASYNC_EMBEDDING = ConfigVar( - 'ENABLE_ASYNC_EMBEDDING', - 'rag.enable_async_embedding', - os.getenv('ENABLE_ASYNC_EMBEDDING', 'True').lower() == 'true', -) +ENABLE_ASYNC_EMBEDDING = os.getenv('ENABLE_ASYNC_EMBEDDING', 'True').lower() == 'true' -RAG_EMBEDDING_CONCURRENT_REQUESTS = ConfigVar( - 'RAG_EMBEDDING_CONCURRENT_REQUESTS', - 'rag.embedding_concurrent_requests', - int(os.getenv('RAG_EMBEDDING_CONCURRENT_REQUESTS', '0')), -) +RAG_EMBEDDING_CONCURRENT_REQUESTS = int(os.getenv('RAG_EMBEDDING_CONCURRENT_REQUESTS', '0')) RAG_EMBEDDING_QUERY_PREFIX = os.getenv('RAG_EMBEDDING_QUERY_PREFIX', None) @@ -1336,19 +936,11 @@ def reachable(host: str, port: int) -> bool: RAG_EMBEDDING_PREFIX_FIELD_NAME = os.getenv('RAG_EMBEDDING_PREFIX_FIELD_NAME', None) -RAG_RERANKING_ENGINE = ConfigVar( - 'RAG_RERANKING_ENGINE', - 'rag.reranking_engine', - os.getenv('RAG_RERANKING_ENGINE', ''), -) +RAG_RERANKING_ENGINE = os.getenv('RAG_RERANKING_ENGINE', '') -RAG_RERANKING_MODEL = ConfigVar( - 'RAG_RERANKING_MODEL', - 'rag.reranking_model', - os.getenv('RAG_RERANKING_MODEL', ''), -) -if RAG_RERANKING_MODEL.value != '': - log.info(f'Reranking model set: {RAG_RERANKING_MODEL.value}') +RAG_RERANKING_MODEL = os.getenv('RAG_RERANKING_MODEL', '') +if RAG_RERANKING_MODEL != '': + log.info(f'Reranking model set: {RAG_RERANKING_MODEL}') RAG_RERANKING_MODEL_AUTO_UPDATE = ( @@ -1357,65 +949,29 @@ def reachable(host: str, port: int) -> bool: RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = os.getenv('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' -RAG_RERANKING_BATCH_SIZE = ConfigVar( - 'RAG_RERANKING_BATCH_SIZE', - 'rag.reranking_batch_size', - int(os.getenv('RAG_RERANKING_BATCH_SIZE', '32')), -) +RAG_RERANKING_BATCH_SIZE = int(os.getenv('RAG_RERANKING_BATCH_SIZE', '32')) -RAG_EXTERNAL_RERANKER_URL = ConfigVar( - 'RAG_EXTERNAL_RERANKER_URL', - 'rag.external_reranker_url', - os.getenv('RAG_EXTERNAL_RERANKER_URL', ''), -) +RAG_EXTERNAL_RERANKER_URL = os.getenv('RAG_EXTERNAL_RERANKER_URL', '') -RAG_EXTERNAL_RERANKER_API_KEY = ConfigVar( - 'RAG_EXTERNAL_RERANKER_API_KEY', - 'rag.external_reranker_api_key', - os.getenv('RAG_EXTERNAL_RERANKER_API_KEY', ''), -) +RAG_EXTERNAL_RERANKER_API_KEY = os.getenv('RAG_EXTERNAL_RERANKER_API_KEY', '') -RAG_EXTERNAL_RERANKER_TIMEOUT = ConfigVar( - 'RAG_EXTERNAL_RERANKER_TIMEOUT', - 'rag.external_reranker_timeout', - os.getenv('RAG_EXTERNAL_RERANKER_TIMEOUT', ''), -) +RAG_EXTERNAL_RERANKER_TIMEOUT = os.getenv('RAG_EXTERNAL_RERANKER_TIMEOUT', '') -RAG_TEXT_SPLITTER = ConfigVar( - 'RAG_TEXT_SPLITTER', - 'rag.text_splitter', - os.getenv('RAG_TEXT_SPLITTER', ''), -) +RAG_TEXT_SPLITTER = os.getenv('RAG_TEXT_SPLITTER', '') -ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ConfigVar( - 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER', - 'rag.enable_markdown_header_text_splitter', - os.getenv('ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER', 'True').lower() == 'true', -) +ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = os.getenv('ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER', 'True').lower() == 'true' TIKTOKEN_CACHE_DIR = os.getenv('TIKTOKEN_CACHE_DIR', f'{CACHE_DIR}/tiktoken') -TIKTOKEN_ENCODING_NAME = ConfigVar( - 'TIKTOKEN_ENCODING_NAME', - 'rag.tiktoken_encoding_name', - os.getenv('TIKTOKEN_ENCODING_NAME', 'cl100k_base'), -) +TIKTOKEN_ENCODING_NAME = os.getenv('TIKTOKEN_ENCODING_NAME', 'cl100k_base') -CHUNK_SIZE = ConfigVar('CHUNK_SIZE', 'rag.chunk_size', int(os.getenv('CHUNK_SIZE', '1000'))) +CHUNK_SIZE = int(os.getenv('CHUNK_SIZE', '1000')) -CHUNK_MIN_SIZE_TARGET = ConfigVar( - 'CHUNK_MIN_SIZE_TARGET', - 'rag.chunk_min_size_target', - int(os.getenv('CHUNK_MIN_SIZE_TARGET', '0')), -) +CHUNK_MIN_SIZE_TARGET = int(os.getenv('CHUNK_MIN_SIZE_TARGET', '0')) -CHUNK_OVERLAP = ConfigVar( - 'CHUNK_OVERLAP', - 'rag.chunk_overlap', - int(os.getenv('CHUNK_OVERLAP', '100')), -) +CHUNK_OVERLAP = int(os.getenv('CHUNK_OVERLAP', '100')) DEFAULT_RAG_TEMPLATE = """### Task: Respond to the user query using the provided context, incorporating inline citations in the format [id] **only when the tag includes an explicit id attribute** (e.g., ). @@ -1443,50 +999,18 @@ def reachable(host: str, port: int) -> bool: """ -RAG_TEMPLATE = ConfigVar( - 'RAG_TEMPLATE', - 'rag.template', - os.getenv('RAG_TEMPLATE', DEFAULT_RAG_TEMPLATE), -) +RAG_TEMPLATE = os.getenv('RAG_TEMPLATE', DEFAULT_RAG_TEMPLATE) -RAG_OPENAI_API_BASE_URL = ConfigVar( - 'RAG_OPENAI_API_BASE_URL', - 'rag.openai_api_base_url', - os.getenv('RAG_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), -) -RAG_OPENAI_API_KEY = ConfigVar( - 'RAG_OPENAI_API_KEY', - 'rag.openai_api_key', - os.getenv('RAG_OPENAI_API_KEY', OPENAI_API_KEY), -) +RAG_OPENAI_API_BASE_URL = os.getenv('RAG_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL) +RAG_OPENAI_API_KEY = os.getenv('RAG_OPENAI_API_KEY', OPENAI_API_KEY) -RAG_AZURE_OPENAI_BASE_URL = ConfigVar( - 'RAG_AZURE_OPENAI_BASE_URL', - 'rag.azure_openai.base_url', - os.getenv('RAG_AZURE_OPENAI_BASE_URL', ''), -) -RAG_AZURE_OPENAI_API_KEY = ConfigVar( - 'RAG_AZURE_OPENAI_API_KEY', - 'rag.azure_openai.api_key', - os.getenv('RAG_AZURE_OPENAI_API_KEY', ''), -) -RAG_AZURE_OPENAI_API_VERSION = ConfigVar( - 'RAG_AZURE_OPENAI_API_VERSION', - 'rag.azure_openai.api_version', - os.getenv('RAG_AZURE_OPENAI_API_VERSION', ''), -) +RAG_AZURE_OPENAI_BASE_URL = os.getenv('RAG_AZURE_OPENAI_BASE_URL', '') +RAG_AZURE_OPENAI_API_KEY = os.getenv('RAG_AZURE_OPENAI_API_KEY', '') +RAG_AZURE_OPENAI_API_VERSION = os.getenv('RAG_AZURE_OPENAI_API_VERSION', '') -RAG_OLLAMA_BASE_URL = ConfigVar( - 'RAG_OLLAMA_BASE_URL', - 'rag.ollama.url', - os.getenv('RAG_OLLAMA_BASE_URL', OLLAMA_BASE_URL), -) +RAG_OLLAMA_BASE_URL = os.getenv('RAG_OLLAMA_BASE_URL', OLLAMA_BASE_URL) -RAG_OLLAMA_API_KEY = ConfigVar( - 'RAG_OLLAMA_API_KEY', - 'rag.ollama.key', - os.getenv('RAG_OLLAMA_API_KEY', ''), -) +RAG_OLLAMA_API_KEY = os.getenv('RAG_OLLAMA_API_KEY', '') ENABLE_RAG_LOCAL_WEB_FETCH = os.getenv('ENABLE_RAG_LOCAL_WEB_FETCH', 'False').lower() == 'true' @@ -1509,53 +1033,25 @@ def reachable(host: str, port: int) -> bool: WEB_FETCH_FILTER_LIST = list(set(DEFAULT_WEB_FETCH_FILTER_LIST + web_fetch_filter_list)) -YOUTUBE_LOADER_LANGUAGE = ConfigVar( - 'YOUTUBE_LOADER_LANGUAGE', - 'rag.youtube_loader_language', - os.getenv('YOUTUBE_LOADER_LANGUAGE', 'en').split(','), -) +YOUTUBE_LOADER_LANGUAGE = os.getenv('YOUTUBE_LOADER_LANGUAGE', 'en').split(',') -YOUTUBE_LOADER_PROXY_URL = ConfigVar( - 'YOUTUBE_LOADER_PROXY_URL', - 'rag.youtube_loader_proxy_url', - os.getenv('YOUTUBE_LOADER_PROXY_URL', ''), -) +YOUTUBE_LOADER_PROXY_URL = os.getenv('YOUTUBE_LOADER_PROXY_URL', '') #################################### # Web Search (RAG) #################################### -ENABLE_WEB_SEARCH = ConfigVar( - 'ENABLE_WEB_SEARCH', - 'rag.web.search.enable', - os.getenv('ENABLE_WEB_SEARCH', 'False').lower() == 'true', -) +ENABLE_WEB_SEARCH = os.getenv('ENABLE_WEB_SEARCH', 'False').lower() == 'true' -WEB_SEARCH_ENGINE = ConfigVar( - 'WEB_SEARCH_ENGINE', - 'rag.web.search.engine', - os.getenv('WEB_SEARCH_ENGINE', ''), -) +WEB_SEARCH_ENGINE = os.getenv('WEB_SEARCH_ENGINE', '') -BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ConfigVar( - 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', - 'rag.web.search.bypass_embedding_and_retrieval', - os.getenv('BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true', -) +BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = os.getenv('BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true' -BYPASS_WEB_SEARCH_WEB_LOADER = ConfigVar( - 'BYPASS_WEB_SEARCH_WEB_LOADER', - 'rag.web.search.bypass_web_loader', - os.getenv('BYPASS_WEB_SEARCH_WEB_LOADER', 'False').lower() == 'true', -) +BYPASS_WEB_SEARCH_WEB_LOADER = os.getenv('BYPASS_WEB_SEARCH_WEB_LOADER', 'False').lower() == 'true' -WEB_SEARCH_RESULT_COUNT = ConfigVar( - 'WEB_SEARCH_RESULT_COUNT', - 'rag.web.search.result_count', - int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3')), -) +WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3')) try: @@ -1570,368 +1066,128 @@ def reachable(host: str, port: int) -> bool: # You can provide a list of your own websites to filter after performing a web search. # This ensures the highest level of safety and reliability of the information sources. -WEB_SEARCH_DOMAIN_FILTER_LIST = ConfigVar( - 'WEB_SEARCH_DOMAIN_FILTER_LIST', - 'rag.web.search.domain.filter_list', - web_search_domain_filter_list, -) +WEB_SEARCH_DOMAIN_FILTER_LIST = web_search_domain_filter_list -WEB_SEARCH_CONCURRENT_REQUESTS = ConfigVar( - 'WEB_SEARCH_CONCURRENT_REQUESTS', - 'rag.web.search.concurrent_requests', - int(os.getenv('WEB_SEARCH_CONCURRENT_REQUESTS', '0')), -) +WEB_SEARCH_CONCURRENT_REQUESTS = int(os.getenv('WEB_SEARCH_CONCURRENT_REQUESTS', '0')) -WEB_FETCH_MAX_CONTENT_LENGTH = ConfigVar( - 'WEB_FETCH_MAX_CONTENT_LENGTH', - 'rag.web.fetch.max_content_length', - (int(os.getenv('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.getenv('WEB_FETCH_MAX_CONTENT_LENGTH') else None), -) +WEB_FETCH_MAX_CONTENT_LENGTH = int(os.getenv('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.getenv('WEB_FETCH_MAX_CONTENT_LENGTH') else None -WEB_LOADER_ENGINE = ConfigVar( - 'WEB_LOADER_ENGINE', - 'rag.web.loader.engine', - os.getenv('WEB_LOADER_ENGINE', ''), -) +WEB_LOADER_ENGINE = os.getenv('WEB_LOADER_ENGINE', '') -WEB_LOADER_CONCURRENT_REQUESTS = ConfigVar( - 'WEB_LOADER_CONCURRENT_REQUESTS', - 'rag.web.loader.concurrent_requests', - int(os.getenv('WEB_LOADER_CONCURRENT_REQUESTS', '10')), -) +WEB_LOADER_CONCURRENT_REQUESTS = int(os.getenv('WEB_LOADER_CONCURRENT_REQUESTS', '10')) -WEB_LOADER_TIMEOUT = ConfigVar( - 'WEB_LOADER_TIMEOUT', - 'rag.web.loader.timeout', - os.getenv('WEB_LOADER_TIMEOUT', ''), -) +WEB_LOADER_TIMEOUT = os.getenv('WEB_LOADER_TIMEOUT', '') -ENABLE_WEB_LOADER_SSL_VERIFICATION = ConfigVar( - 'ENABLE_WEB_LOADER_SSL_VERIFICATION', - 'rag.web.loader.ssl_verification', - os.getenv('ENABLE_WEB_LOADER_SSL_VERIFICATION', 'True').lower() == 'true', -) +ENABLE_WEB_LOADER_SSL_VERIFICATION = os.getenv('ENABLE_WEB_LOADER_SSL_VERIFICATION', 'True').lower() == 'true' -WEB_SEARCH_TRUST_ENV = ConfigVar( - 'WEB_SEARCH_TRUST_ENV', - 'rag.web.search.trust_env', - os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true', -) +WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true' -OLLAMA_CLOUD_WEB_SEARCH_API_KEY = ConfigVar( - 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY', - 'rag.web.search.ollama_cloud_api_key', - os.getenv('OLLAMA_CLOUD_API_KEY', ''), -) +OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '') -SEARXNG_QUERY_URL = ConfigVar( - 'SEARXNG_QUERY_URL', - 'rag.web.search.searxng_query_url', - os.getenv('SEARXNG_QUERY_URL', ''), -) +SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '') -SEARXNG_LANGUAGE = ConfigVar( - 'SEARXNG_LANGUAGE', - 'rag.web.search.searxng_language', - os.getenv('SEARXNG_LANGUAGE', 'all'), -) +SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all') -YACY_QUERY_URL = ConfigVar( - 'YACY_QUERY_URL', - 'rag.web.search.yacy_query_url', - os.getenv('YACY_QUERY_URL', ''), -) +YACY_QUERY_URL = os.getenv('YACY_QUERY_URL', '') -YACY_USERNAME = ConfigVar( - 'YACY_USERNAME', - 'rag.web.search.yacy_username', - os.getenv('YACY_USERNAME', ''), -) +YACY_USERNAME = os.getenv('YACY_USERNAME', '') -YACY_PASSWORD = ConfigVar( - 'YACY_PASSWORD', - 'rag.web.search.yacy_password', - os.getenv('YACY_PASSWORD', ''), -) +YACY_PASSWORD = os.getenv('YACY_PASSWORD', '') -GOOGLE_PSE_API_KEY = ConfigVar( - 'GOOGLE_PSE_API_KEY', - 'rag.web.search.google_pse_api_key', - os.getenv('GOOGLE_PSE_API_KEY', ''), -) +GOOGLE_PSE_API_KEY = os.getenv('GOOGLE_PSE_API_KEY', '') -GOOGLE_PSE_ENGINE_ID = ConfigVar( - 'GOOGLE_PSE_ENGINE_ID', - 'rag.web.search.google_pse_engine_id', - os.getenv('GOOGLE_PSE_ENGINE_ID', ''), -) +GOOGLE_PSE_ENGINE_ID = os.getenv('GOOGLE_PSE_ENGINE_ID', '') -BRAVE_SEARCH_API_KEY = ConfigVar( - 'BRAVE_SEARCH_API_KEY', - 'rag.web.search.brave_search_api_key', - os.getenv('BRAVE_SEARCH_API_KEY', ''), -) +BRAVE_SEARCH_API_KEY = os.getenv('BRAVE_SEARCH_API_KEY', '') -BRAVE_SEARCH_CONTEXT_TOKENS = ConfigVar( - 'BRAVE_SEARCH_CONTEXT_TOKENS', - 'rag.web.search.brave_search_context_tokens', - int(os.getenv('BRAVE_SEARCH_CONTEXT_TOKENS', '8192')), -) +BRAVE_SEARCH_CONTEXT_TOKENS = int(os.getenv('BRAVE_SEARCH_CONTEXT_TOKENS', '8192')) -KAGI_SEARCH_API_KEY = ConfigVar( - 'KAGI_SEARCH_API_KEY', - 'rag.web.search.kagi_search_api_key', - os.getenv('KAGI_SEARCH_API_KEY', ''), -) +KAGI_SEARCH_API_KEY = os.getenv('KAGI_SEARCH_API_KEY', '') -MOJEEK_SEARCH_API_KEY = ConfigVar( - 'MOJEEK_SEARCH_API_KEY', - 'rag.web.search.mojeek_search_api_key', - os.getenv('MOJEEK_SEARCH_API_KEY', ''), -) +MOJEEK_SEARCH_API_KEY = os.getenv('MOJEEK_SEARCH_API_KEY', '') -BOCHA_SEARCH_API_KEY = ConfigVar( - 'BOCHA_SEARCH_API_KEY', - 'rag.web.search.bocha_search_api_key', - os.getenv('BOCHA_SEARCH_API_KEY', ''), -) +BOCHA_SEARCH_API_KEY = os.getenv('BOCHA_SEARCH_API_KEY', '') -SERPSTACK_API_KEY = ConfigVar( - 'SERPSTACK_API_KEY', - 'rag.web.search.serpstack_api_key', - os.getenv('SERPSTACK_API_KEY', ''), -) +SERPSTACK_API_KEY = os.getenv('SERPSTACK_API_KEY', '') -SERPSTACK_HTTPS = ConfigVar( - 'SERPSTACK_HTTPS', - 'rag.web.search.serpstack_https', - os.getenv('SERPSTACK_HTTPS', 'True').lower() == 'true', -) +SERPSTACK_HTTPS = os.getenv('SERPSTACK_HTTPS', 'True').lower() == 'true' -SERPER_API_KEY = ConfigVar( - 'SERPER_API_KEY', - 'rag.web.search.serper_api_key', - os.getenv('SERPER_API_KEY', ''), -) +SERPER_API_KEY = os.getenv('SERPER_API_KEY', '') -SERPLY_API_KEY = ConfigVar( - 'SERPLY_API_KEY', - 'rag.web.search.serply_api_key', - os.getenv('SERPLY_API_KEY', ''), -) +SERPLY_API_KEY = os.getenv('SERPLY_API_KEY', '') -DDGS_BACKEND = ConfigVar( - 'DDGS_BACKEND', - 'rag.web.search.ddgs_backend', - os.getenv('DDGS_BACKEND', 'auto'), -) +DDGS_BACKEND = os.getenv('DDGS_BACKEND', 'auto') -JINA_API_KEY = ConfigVar( - 'JINA_API_KEY', - 'rag.web.search.jina_api_key', - os.getenv('JINA_API_KEY', ''), -) +JINA_API_KEY = os.getenv('JINA_API_KEY', '') -JINA_API_BASE_URL = ConfigVar( - 'JINA_API_BASE_URL', - 'rag.web.search.jina_api_base_url', - os.getenv('JINA_API_BASE_URL', ''), -) +JINA_API_BASE_URL = os.getenv('JINA_API_BASE_URL', '') -SEARCHAPI_API_KEY = ConfigVar( - 'SEARCHAPI_API_KEY', - 'rag.web.search.searchapi_api_key', - os.getenv('SEARCHAPI_API_KEY', ''), -) +SEARCHAPI_API_KEY = os.getenv('SEARCHAPI_API_KEY', '') -SEARCHAPI_ENGINE = ConfigVar( - 'SEARCHAPI_ENGINE', - 'rag.web.search.searchapi_engine', - os.getenv('SEARCHAPI_ENGINE', ''), -) +SEARCHAPI_ENGINE = os.getenv('SEARCHAPI_ENGINE', '') -SERPAPI_API_KEY = ConfigVar( - 'SERPAPI_API_KEY', - 'rag.web.search.serpapi_api_key', - os.getenv('SERPAPI_API_KEY', ''), -) +SERPAPI_API_KEY = os.getenv('SERPAPI_API_KEY', '') -SERPAPI_ENGINE = ConfigVar( - 'SERPAPI_ENGINE', - 'rag.web.search.serpapi_engine', - os.getenv('SERPAPI_ENGINE', ''), -) +SERPAPI_ENGINE = os.getenv('SERPAPI_ENGINE', '') -BING_SEARCH_V7_ENDPOINT = ConfigVar( - 'BING_SEARCH_V7_ENDPOINT', - 'rag.web.search.bing_search_v7_endpoint', - os.getenv('BING_SEARCH_V7_ENDPOINT', 'https://api.bing.microsoft.com/v7.0/search'), -) +BING_SEARCH_V7_ENDPOINT = os.getenv('BING_SEARCH_V7_ENDPOINT', 'https://api.bing.microsoft.com/v7.0/search') -BING_SEARCH_V7_SUBSCRIPTION_KEY = ConfigVar( - 'BING_SEARCH_V7_SUBSCRIPTION_KEY', - 'rag.web.search.bing_search_v7_subscription_key', - os.getenv('BING_SEARCH_V7_SUBSCRIPTION_KEY', ''), -) +BING_SEARCH_V7_SUBSCRIPTION_KEY = os.getenv('BING_SEARCH_V7_SUBSCRIPTION_KEY', '') -AZURE_AI_SEARCH_API_KEY = ConfigVar( - 'AZURE_AI_SEARCH_API_KEY', - 'rag.web.search.azure_ai_search_api_key', - os.getenv('AZURE_AI_SEARCH_API_KEY', ''), -) +AZURE_AI_SEARCH_API_KEY = os.getenv('AZURE_AI_SEARCH_API_KEY', '') -AZURE_AI_SEARCH_ENDPOINT = ConfigVar( - 'AZURE_AI_SEARCH_ENDPOINT', - 'rag.web.search.azure_ai_search_endpoint', - os.getenv('AZURE_AI_SEARCH_ENDPOINT', ''), -) +AZURE_AI_SEARCH_ENDPOINT = os.getenv('AZURE_AI_SEARCH_ENDPOINT', '') -AZURE_AI_SEARCH_INDEX_NAME = ConfigVar( - 'AZURE_AI_SEARCH_INDEX_NAME', - 'rag.web.search.azure_ai_search_index_name', - os.getenv('AZURE_AI_SEARCH_INDEX_NAME', ''), -) +AZURE_AI_SEARCH_INDEX_NAME = os.getenv('AZURE_AI_SEARCH_INDEX_NAME', '') -EXA_API_KEY = ConfigVar( - 'EXA_API_KEY', - 'rag.web.search.exa_api_key', - os.getenv('EXA_API_KEY', ''), -) +EXA_API_KEY = os.getenv('EXA_API_KEY', '') -PERPLEXITY_API_KEY = ConfigVar( - 'PERPLEXITY_API_KEY', - 'rag.web.search.perplexity_api_key', - os.getenv('PERPLEXITY_API_KEY', ''), -) +PERPLEXITY_API_KEY = os.getenv('PERPLEXITY_API_KEY', '') -PERPLEXITY_MODEL = ConfigVar( - 'PERPLEXITY_MODEL', - 'rag.web.search.perplexity_model', - os.getenv('PERPLEXITY_MODEL', 'sonar'), -) +PERPLEXITY_MODEL = os.getenv('PERPLEXITY_MODEL', 'sonar') -PERPLEXITY_SEARCH_CONTEXT_USAGE = ConfigVar( - 'PERPLEXITY_SEARCH_CONTEXT_USAGE', - 'rag.web.search.perplexity_search_context_usage', - os.getenv('PERPLEXITY_SEARCH_CONTEXT_USAGE', 'medium'), -) +PERPLEXITY_SEARCH_CONTEXT_USAGE = os.getenv('PERPLEXITY_SEARCH_CONTEXT_USAGE', 'medium') -PERPLEXITY_SEARCH_API_URL = ConfigVar( - 'PERPLEXITY_SEARCH_API_URL', - 'rag.web.search.perplexity_search_api_url', - os.getenv('PERPLEXITY_SEARCH_API_URL', 'https://api.perplexity.ai/search'), -) +PERPLEXITY_SEARCH_API_URL = os.getenv('PERPLEXITY_SEARCH_API_URL', 'https://api.perplexity.ai/search') -SOUGOU_API_SID = ConfigVar( - 'SOUGOU_API_SID', - 'rag.web.search.sougou_api_sid', - os.getenv('SOUGOU_API_SID', ''), -) +SOUGOU_API_SID = os.getenv('SOUGOU_API_SID', '') -SOUGOU_API_SK = ConfigVar( - 'SOUGOU_API_SK', - 'rag.web.search.sougou_api_sk', - os.getenv('SOUGOU_API_SK', ''), -) +SOUGOU_API_SK = os.getenv('SOUGOU_API_SK', '') -TAVILY_API_KEY = ConfigVar( - 'TAVILY_API_KEY', - 'rag.web.search.tavily_api_key', - os.getenv('TAVILY_API_KEY', ''), -) +TAVILY_API_KEY = os.getenv('TAVILY_API_KEY', '') -TAVILY_EXTRACT_DEPTH = ConfigVar( - 'TAVILY_EXTRACT_DEPTH', - 'rag.web.search.tavily_extract_depth', - os.getenv('TAVILY_EXTRACT_DEPTH', 'basic'), -) +TAVILY_EXTRACT_DEPTH = os.getenv('TAVILY_EXTRACT_DEPTH', 'basic') -PLAYWRIGHT_WS_URL = ConfigVar( - 'PLAYWRIGHT_WS_URL', - 'rag.web.loader.playwright_ws_url', - os.getenv('PLAYWRIGHT_WS_URL', ''), -) +PLAYWRIGHT_WS_URL = os.getenv('PLAYWRIGHT_WS_URL', '') -PLAYWRIGHT_TIMEOUT = ConfigVar( - 'PLAYWRIGHT_TIMEOUT', - 'rag.web.loader.playwright_timeout', - int(os.getenv('PLAYWRIGHT_TIMEOUT', '10000')), -) +PLAYWRIGHT_TIMEOUT = int(os.getenv('PLAYWRIGHT_TIMEOUT', '10000')) -FIRECRAWL_API_KEY = ConfigVar( - 'FIRECRAWL_API_KEY', - 'rag.web.loader.firecrawl_api_key', - os.getenv('FIRECRAWL_API_KEY', ''), -) +FIRECRAWL_API_KEY = os.getenv('FIRECRAWL_API_KEY', '') -FIRECRAWL_API_BASE_URL = ConfigVar( - 'FIRECRAWL_API_BASE_URL', - 'rag.web.loader.firecrawl_api_url', - os.getenv('FIRECRAWL_API_BASE_URL', 'https://api.firecrawl.dev'), -) +FIRECRAWL_API_BASE_URL = os.getenv('FIRECRAWL_API_BASE_URL', 'https://api.firecrawl.dev') -FIRECRAWL_TIMEOUT = ConfigVar( - 'FIRECRAWL_TIMEOUT', - 'rag.web.loader.firecrawl_timeout', - os.getenv('FIRECRAWL_TIMEOUT', ''), -) +FIRECRAWL_TIMEOUT = os.getenv('FIRECRAWL_TIMEOUT', '') -EXTERNAL_WEB_SEARCH_URL = ConfigVar( - 'EXTERNAL_WEB_SEARCH_URL', - 'rag.web.search.external_web_search_url', - os.getenv('EXTERNAL_WEB_SEARCH_URL', ''), -) +EXTERNAL_WEB_SEARCH_URL = os.getenv('EXTERNAL_WEB_SEARCH_URL', '') -EXTERNAL_WEB_SEARCH_API_KEY = ConfigVar( - 'EXTERNAL_WEB_SEARCH_API_KEY', - 'rag.web.search.external_web_search_api_key', - os.getenv('EXTERNAL_WEB_SEARCH_API_KEY', ''), -) +EXTERNAL_WEB_SEARCH_API_KEY = os.getenv('EXTERNAL_WEB_SEARCH_API_KEY', '') -EXTERNAL_WEB_LOADER_URL = ConfigVar( - 'EXTERNAL_WEB_LOADER_URL', - 'rag.web.loader.external_web_loader_url', - os.getenv('EXTERNAL_WEB_LOADER_URL', ''), -) +EXTERNAL_WEB_LOADER_URL = os.getenv('EXTERNAL_WEB_LOADER_URL', '') -EXTERNAL_WEB_LOADER_API_KEY = ConfigVar( - 'EXTERNAL_WEB_LOADER_API_KEY', - 'rag.web.loader.external_web_loader_api_key', - os.getenv('EXTERNAL_WEB_LOADER_API_KEY', ''), -) +EXTERNAL_WEB_LOADER_API_KEY = os.getenv('EXTERNAL_WEB_LOADER_API_KEY', '') -YANDEX_WEB_SEARCH_URL = ConfigVar( - 'YANDEX_WEB_SEARCH_URL', - 'rag.web.search.yandex_web_search_url', - os.getenv('YANDEX_WEB_SEARCH_URL', ''), -) +YANDEX_WEB_SEARCH_URL = os.getenv('YANDEX_WEB_SEARCH_URL', '') -YANDEX_WEB_SEARCH_API_KEY = ConfigVar( - 'YANDEX_WEB_SEARCH_API_KEY', - 'rag.web.search.yandex_web_search_api_key', - os.getenv('YANDEX_WEB_SEARCH_API_KEY', ''), -) +YANDEX_WEB_SEARCH_API_KEY = os.getenv('YANDEX_WEB_SEARCH_API_KEY', '') -YANDEX_WEB_SEARCH_CONFIG = ConfigVar( - 'YANDEX_WEB_SEARCH_CONFIG', - 'rag.web.search.yandex_web_search_config', - os.getenv('YANDEX_WEB_SEARCH_CONFIG', ''), -) +YANDEX_WEB_SEARCH_CONFIG = os.getenv('YANDEX_WEB_SEARCH_CONFIG', '') -YOUCOM_API_KEY = ConfigVar( - 'YOUCOM_API_KEY', - 'rag.web.search.youcom_api_key', - os.getenv('YOUCOM_API_KEY', ''), -) +YOUCOM_API_KEY = os.getenv('YOUCOM_API_KEY', '') -LINKUP_API_KEY = ConfigVar( - 'LINKUP_API_KEY', - 'rag.web.search.linkup_api_key', - os.getenv('LINKUP_API_KEY', ''), -) +LINKUP_API_KEY = os.getenv('LINKUP_API_KEY', '') linkup_search_params = os.getenv('LINKUP_SEARCH_PARAMS', '') try: @@ -1939,33 +1195,17 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: linkup_search_params = {} -LINKUP_SEARCH_PARAMS = ConfigVar( - 'LINKUP_SEARCH_PARAMS', - 'rag.web.search.linkup_search_params', - linkup_search_params, -) +LINKUP_SEARCH_PARAMS = linkup_search_params #################################### # Images #################################### -ENABLE_IMAGE_GENERATION = ConfigVar( - 'ENABLE_IMAGE_GENERATION', - 'image_generation.enable', - os.getenv('ENABLE_IMAGE_GENERATION', '').lower() == 'true', -) +ENABLE_IMAGE_GENERATION = os.getenv('ENABLE_IMAGE_GENERATION', '').lower() == 'true' -IMAGE_GENERATION_ENGINE = ConfigVar( - 'IMAGE_GENERATION_ENGINE', - 'image_generation.engine', - os.getenv('IMAGE_GENERATION_ENGINE', 'openai'), -) +IMAGE_GENERATION_ENGINE = os.getenv('IMAGE_GENERATION_ENGINE', 'openai') -IMAGE_GENERATION_MODEL = ConfigVar( - 'IMAGE_GENERATION_MODEL', - 'image_generation.model', - os.getenv('IMAGE_GENERATION_MODEL', ''), -) +IMAGE_GENERATION_MODEL = os.getenv('IMAGE_GENERATION_MODEL', '') # Regex pattern for models that support IMAGE_SIZE = "auto". IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv('IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN', '^gpt-image') @@ -1973,26 +1213,14 @@ def reachable(host: str, port: int) -> bool: # Regex pattern for models that return URLs instead of base64 data. IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv('IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN', '^gpt-image') -IMAGE_SIZE = ConfigVar('IMAGE_SIZE', 'image_generation.size', os.getenv('IMAGE_SIZE', '512x512')) +IMAGE_SIZE = os.getenv('IMAGE_SIZE', '512x512') -IMAGE_STEPS = ConfigVar('IMAGE_STEPS', 'image_generation.steps', int(os.getenv('IMAGE_STEPS', 50))) +IMAGE_STEPS = int(os.getenv('IMAGE_STEPS', 50)) -ENABLE_IMAGE_PROMPT_GENERATION = ConfigVar( - 'ENABLE_IMAGE_PROMPT_GENERATION', - 'image_generation.prompt.enable', - os.getenv('ENABLE_IMAGE_PROMPT_GENERATION', 'true').lower() == 'true', -) +ENABLE_IMAGE_PROMPT_GENERATION = os.getenv('ENABLE_IMAGE_PROMPT_GENERATION', 'true').lower() == 'true' -AUTOMATIC1111_BASE_URL = ConfigVar( - 'AUTOMATIC1111_BASE_URL', - 'image_generation.automatic1111.base_url', - os.getenv('AUTOMATIC1111_BASE_URL', ''), -) -AUTOMATIC1111_API_AUTH = ConfigVar( - 'AUTOMATIC1111_API_AUTH', - 'image_generation.automatic1111.api_auth', - os.getenv('AUTOMATIC1111_API_AUTH', ''), -) +AUTOMATIC1111_BASE_URL = os.getenv('AUTOMATIC1111_BASE_URL', '') +AUTOMATIC1111_API_AUTH = os.getenv('AUTOMATIC1111_API_AUTH', '') automatic1111_params = os.getenv('AUTOMATIC1111_PARAMS', '') try: @@ -2000,23 +1228,11 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: automatic1111_params = {} -AUTOMATIC1111_PARAMS = ConfigVar( - 'AUTOMATIC1111_PARAMS', - 'image_generation.automatic1111.api_params', - automatic1111_params, -) +AUTOMATIC1111_PARAMS = automatic1111_params -COMFYUI_BASE_URL = ConfigVar( - 'COMFYUI_BASE_URL', - 'image_generation.comfyui.base_url', - os.getenv('COMFYUI_BASE_URL', ''), -) +COMFYUI_BASE_URL = os.getenv('COMFYUI_BASE_URL', '') -COMFYUI_API_KEY = ConfigVar( - 'COMFYUI_API_KEY', - 'image_generation.comfyui.api_key', - os.getenv('COMFYUI_API_KEY', ''), -) +COMFYUI_API_KEY = os.getenv('COMFYUI_API_KEY', '') COMFYUI_DEFAULT_WORKFLOW = """ { @@ -2129,11 +1345,7 @@ def reachable(host: str, port: int) -> bool: """ -COMFYUI_WORKFLOW = ConfigVar( - 'COMFYUI_WORKFLOW', - 'image_generation.comfyui.workflow', - os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW), -) +COMFYUI_WORKFLOW = os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW) comfyui_workflow_nodes = os.getenv('COMFYUI_WORKFLOW_NODES', '') try: @@ -2141,28 +1353,12 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: comfyui_workflow_nodes = [] -COMFYUI_WORKFLOW_NODES = ConfigVar( - 'COMFYUI_WORKFLOW_NODES', - 'image_generation.comfyui.nodes', - comfyui_workflow_nodes, -) +COMFYUI_WORKFLOW_NODES = comfyui_workflow_nodes -IMAGES_OPENAI_API_BASE_URL = ConfigVar( - 'IMAGES_OPENAI_API_BASE_URL', - 'image_generation.openai.api_base_url', - os.getenv('IMAGES_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), -) -IMAGES_OPENAI_API_VERSION = ConfigVar( - 'IMAGES_OPENAI_API_VERSION', - 'image_generation.openai.api_version', - os.getenv('IMAGES_OPENAI_API_VERSION', ''), -) +IMAGES_OPENAI_API_BASE_URL = os.getenv('IMAGES_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL) +IMAGES_OPENAI_API_VERSION = os.getenv('IMAGES_OPENAI_API_VERSION', '') -IMAGES_OPENAI_API_KEY = ConfigVar( - 'IMAGES_OPENAI_API_KEY', - 'image_generation.openai.api_key', - os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY), -) +IMAGES_OPENAI_API_KEY = os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY) images_openai_params = os.getenv('IMAGES_OPENAI_PARAMS', '') try: @@ -2171,91 +1367,35 @@ def reachable(host: str, port: int) -> bool: images_openai_params = {} -IMAGES_OPENAI_API_PARAMS = ConfigVar('IMAGES_OPENAI_API_PARAMS', 'image_generation.openai.params', images_openai_params) +IMAGES_OPENAI_API_PARAMS = images_openai_params -IMAGES_GEMINI_API_BASE_URL = ConfigVar( - 'IMAGES_GEMINI_API_BASE_URL', - 'image_generation.gemini.api_base_url', - os.getenv('IMAGES_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL), -) -IMAGES_GEMINI_API_KEY = ConfigVar( - 'IMAGES_GEMINI_API_KEY', - 'image_generation.gemini.api_key', - os.getenv('IMAGES_GEMINI_API_KEY', GEMINI_API_KEY), -) +IMAGES_GEMINI_API_BASE_URL = os.getenv('IMAGES_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL) +IMAGES_GEMINI_API_KEY = os.getenv('IMAGES_GEMINI_API_KEY', GEMINI_API_KEY) -IMAGES_GEMINI_ENDPOINT_METHOD = ConfigVar( - 'IMAGES_GEMINI_ENDPOINT_METHOD', - 'image_generation.gemini.endpoint_method', - os.getenv('IMAGES_GEMINI_ENDPOINT_METHOD', ''), -) +IMAGES_GEMINI_ENDPOINT_METHOD = os.getenv('IMAGES_GEMINI_ENDPOINT_METHOD', '') -ENABLE_IMAGE_EDIT = ConfigVar( - 'ENABLE_IMAGE_EDIT', - 'images.edit.enable', - os.getenv('ENABLE_IMAGE_EDIT', '').lower() == 'true', -) +ENABLE_IMAGE_EDIT = os.getenv('ENABLE_IMAGE_EDIT', '').lower() == 'true' -IMAGE_EDIT_ENGINE = ConfigVar( - 'IMAGE_EDIT_ENGINE', - 'images.edit.engine', - os.getenv('IMAGE_EDIT_ENGINE', 'openai'), -) +IMAGE_EDIT_ENGINE = os.getenv('IMAGE_EDIT_ENGINE', 'openai') -IMAGE_EDIT_MODEL = ConfigVar( - 'IMAGE_EDIT_MODEL', - 'images.edit.model', - os.getenv('IMAGE_EDIT_MODEL', ''), -) +IMAGE_EDIT_MODEL = os.getenv('IMAGE_EDIT_MODEL', '') -IMAGE_EDIT_SIZE = ConfigVar('IMAGE_EDIT_SIZE', 'images.edit.size', os.getenv('IMAGE_EDIT_SIZE', '')) +IMAGE_EDIT_SIZE = os.getenv('IMAGE_EDIT_SIZE', '') -IMAGES_EDIT_OPENAI_API_BASE_URL = ConfigVar( - 'IMAGES_EDIT_OPENAI_API_BASE_URL', - 'images.edit.openai.api_base_url', - os.getenv('IMAGES_EDIT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), -) -IMAGES_EDIT_OPENAI_API_VERSION = ConfigVar( - 'IMAGES_EDIT_OPENAI_API_VERSION', - 'images.edit.openai.api_version', - os.getenv('IMAGES_EDIT_OPENAI_API_VERSION', ''), -) +IMAGES_EDIT_OPENAI_API_BASE_URL = os.getenv('IMAGES_EDIT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL) +IMAGES_EDIT_OPENAI_API_VERSION = os.getenv('IMAGES_EDIT_OPENAI_API_VERSION', '') -IMAGES_EDIT_OPENAI_API_KEY = ConfigVar( - 'IMAGES_EDIT_OPENAI_API_KEY', - 'images.edit.openai.api_key', - os.getenv('IMAGES_EDIT_OPENAI_API_KEY', OPENAI_API_KEY), -) +IMAGES_EDIT_OPENAI_API_KEY = os.getenv('IMAGES_EDIT_OPENAI_API_KEY', OPENAI_API_KEY) -IMAGES_EDIT_GEMINI_API_BASE_URL = ConfigVar( - 'IMAGES_EDIT_GEMINI_API_BASE_URL', - 'images.edit.gemini.api_base_url', - os.getenv('IMAGES_EDIT_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL), -) -IMAGES_EDIT_GEMINI_API_KEY = ConfigVar( - 'IMAGES_EDIT_GEMINI_API_KEY', - 'images.edit.gemini.api_key', - os.getenv('IMAGES_EDIT_GEMINI_API_KEY', GEMINI_API_KEY), -) +IMAGES_EDIT_GEMINI_API_BASE_URL = os.getenv('IMAGES_EDIT_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL) +IMAGES_EDIT_GEMINI_API_KEY = os.getenv('IMAGES_EDIT_GEMINI_API_KEY', GEMINI_API_KEY) -IMAGES_EDIT_COMFYUI_BASE_URL = ConfigVar( - 'IMAGES_EDIT_COMFYUI_BASE_URL', - 'images.edit.comfyui.base_url', - os.getenv('IMAGES_EDIT_COMFYUI_BASE_URL', ''), -) -IMAGES_EDIT_COMFYUI_API_KEY = ConfigVar( - 'IMAGES_EDIT_COMFYUI_API_KEY', - 'images.edit.comfyui.api_key', - os.getenv('IMAGES_EDIT_COMFYUI_API_KEY', ''), -) +IMAGES_EDIT_COMFYUI_BASE_URL = os.getenv('IMAGES_EDIT_COMFYUI_BASE_URL', '') +IMAGES_EDIT_COMFYUI_API_KEY = os.getenv('IMAGES_EDIT_COMFYUI_API_KEY', '') -IMAGES_EDIT_COMFYUI_WORKFLOW = ConfigVar( - 'IMAGES_EDIT_COMFYUI_WORKFLOW', - 'images.edit.comfyui.workflow', - os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', ''), -) +IMAGES_EDIT_COMFYUI_WORKFLOW = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', '') images_edit_comfyui_workflow_nodes = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', '') try: @@ -2263,22 +1403,14 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: images_edit_comfyui_workflow_nodes = [] -IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = ConfigVar( - 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', - 'images.edit.comfyui.nodes', - images_edit_comfyui_workflow_nodes, -) +IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = images_edit_comfyui_workflow_nodes #################################### # Audio #################################### # Transcription -WHISPER_MODEL = ConfigVar( - 'WHISPER_MODEL', - 'audio.stt.whisper_model', - os.getenv('WHISPER_MODEL', 'base'), -) +WHISPER_MODEL = os.getenv('WHISPER_MODEL', 'base') WHISPER_COMPUTE_TYPE = os.getenv('WHISPER_COMPUTE_TYPE', 'int8') WHISPER_MODEL_DIR = os.getenv('WHISPER_MODEL_DIR', f'{CACHE_DIR}/whisper/models') @@ -2291,120 +1423,41 @@ def reachable(host: str, port: int) -> bool: WHISPER_LANGUAGE = os.getenv('WHISPER_LANGUAGE', '').lower() or None # Add Deepgram configuration -DEEPGRAM_API_KEY = ConfigVar( - 'DEEPGRAM_API_KEY', - 'audio.stt.deepgram.api_key', - os.getenv('DEEPGRAM_API_KEY', ''), -) +DEEPGRAM_API_KEY = os.getenv('DEEPGRAM_API_KEY', '') # ElevenLabs configuration ELEVENLABS_API_BASE_URL = os.getenv('ELEVENLABS_API_BASE_URL', 'https://api.elevenlabs.io') -AUDIO_STT_OPENAI_API_BASE_URL = ConfigVar( - 'AUDIO_STT_OPENAI_API_BASE_URL', - 'audio.stt.openai.api_base_url', - os.getenv('AUDIO_STT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), -) +AUDIO_STT_OPENAI_API_BASE_URL = os.getenv('AUDIO_STT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL) -AUDIO_STT_OPENAI_API_KEY = ConfigVar( - 'AUDIO_STT_OPENAI_API_KEY', - 'audio.stt.openai.api_key', - os.getenv('AUDIO_STT_OPENAI_API_KEY', OPENAI_API_KEY), -) +AUDIO_STT_OPENAI_API_KEY = os.getenv('AUDIO_STT_OPENAI_API_KEY', OPENAI_API_KEY) -AUDIO_STT_ENGINE = ConfigVar( - 'AUDIO_STT_ENGINE', - 'audio.stt.engine', - os.getenv('AUDIO_STT_ENGINE', ''), -) +AUDIO_STT_ENGINE = os.getenv('AUDIO_STT_ENGINE', '') -AUDIO_STT_MODEL = ConfigVar( - 'AUDIO_STT_MODEL', - 'audio.stt.model', - os.getenv('AUDIO_STT_MODEL', ''), -) +AUDIO_STT_MODEL = os.getenv('AUDIO_STT_MODEL', '') -AUDIO_STT_SUPPORTED_CONTENT_TYPES = ConfigVar( - 'AUDIO_STT_SUPPORTED_CONTENT_TYPES', - 'audio.stt.supported_content_types', - [ - content_type.strip() - for content_type in os.getenv('AUDIO_STT_SUPPORTED_CONTENT_TYPES', '').split(',') - if content_type.strip() - ], -) +AUDIO_STT_SUPPORTED_CONTENT_TYPES = [ content_type.strip() for content_type in os.getenv('AUDIO_STT_SUPPORTED_CONTENT_TYPES', '').split(',') if content_type.strip() ] -AUDIO_STT_ALLOWED_EXTENSIONS = ConfigVar( - 'AUDIO_STT_ALLOWED_EXTENSIONS', - 'audio.stt.allowed_extensions', - [ - ext.strip() - for ext in os.getenv( - 'AUDIO_STT_ALLOWED_EXTENSIONS', - 'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg', - ).split(',') - if ext.strip() - ], -) +AUDIO_STT_ALLOWED_EXTENSIONS = [ ext.strip() for ext in os.getenv( 'AUDIO_STT_ALLOWED_EXTENSIONS', 'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg', ).split(',') if ext.strip() ] -AUDIO_STT_AZURE_API_KEY = ConfigVar( - 'AUDIO_STT_AZURE_API_KEY', - 'audio.stt.azure.api_key', - os.getenv('AUDIO_STT_AZURE_API_KEY', ''), -) +AUDIO_STT_AZURE_API_KEY = os.getenv('AUDIO_STT_AZURE_API_KEY', '') -AUDIO_STT_AZURE_REGION = ConfigVar( - 'AUDIO_STT_AZURE_REGION', - 'audio.stt.azure.region', - os.getenv('AUDIO_STT_AZURE_REGION', ''), -) +AUDIO_STT_AZURE_REGION = os.getenv('AUDIO_STT_AZURE_REGION', '') -AUDIO_STT_AZURE_LOCALES = ConfigVar( - 'AUDIO_STT_AZURE_LOCALES', - 'audio.stt.azure.locales', - os.getenv('AUDIO_STT_AZURE_LOCALES', ''), -) +AUDIO_STT_AZURE_LOCALES = os.getenv('AUDIO_STT_AZURE_LOCALES', '') -AUDIO_STT_AZURE_BASE_URL = ConfigVar( - 'AUDIO_STT_AZURE_BASE_URL', - 'audio.stt.azure.base_url', - os.getenv('AUDIO_STT_AZURE_BASE_URL', ''), -) +AUDIO_STT_AZURE_BASE_URL = os.getenv('AUDIO_STT_AZURE_BASE_URL', '') -AUDIO_STT_AZURE_MAX_SPEAKERS = ConfigVar( - 'AUDIO_STT_AZURE_MAX_SPEAKERS', - 'audio.stt.azure.max_speakers', - os.getenv('AUDIO_STT_AZURE_MAX_SPEAKERS', ''), -) +AUDIO_STT_AZURE_MAX_SPEAKERS = os.getenv('AUDIO_STT_AZURE_MAX_SPEAKERS', '') -AUDIO_STT_MISTRAL_API_KEY = ConfigVar( - 'AUDIO_STT_MISTRAL_API_KEY', - 'audio.stt.mistral.api_key', - os.getenv('AUDIO_STT_MISTRAL_API_KEY', ''), -) +AUDIO_STT_MISTRAL_API_KEY = os.getenv('AUDIO_STT_MISTRAL_API_KEY', '') -AUDIO_STT_MISTRAL_API_BASE_URL = ConfigVar( - 'AUDIO_STT_MISTRAL_API_BASE_URL', - 'audio.stt.mistral.api_base_url', - os.getenv('AUDIO_STT_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'), -) +AUDIO_STT_MISTRAL_API_BASE_URL = os.getenv('AUDIO_STT_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1') -AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = ConfigVar( - 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS', - 'audio.stt.mistral.use_chat_completions', - os.getenv('AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS', 'false').lower() == 'true', -) +AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = os.getenv('AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS', 'false').lower() == 'true' -AUDIO_TTS_OPENAI_API_BASE_URL = ConfigVar( - 'AUDIO_TTS_OPENAI_API_BASE_URL', - 'audio.tts.openai.api_base_url', - os.getenv('AUDIO_TTS_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), -) -AUDIO_TTS_OPENAI_API_KEY = ConfigVar( - 'AUDIO_TTS_OPENAI_API_KEY', - 'audio.tts.openai.api_key', - os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY), -) +AUDIO_TTS_OPENAI_API_BASE_URL = os.getenv('AUDIO_TTS_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL) +AUDIO_TTS_OPENAI_API_KEY = os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY) audio_tts_openai_params = os.getenv('AUDIO_TTS_OPENAI_PARAMS', '') try: @@ -2412,115 +1465,51 @@ def reachable(host: str, port: int) -> bool: except json.JSONDecodeError: audio_tts_openai_params = {} -AUDIO_TTS_OPENAI_PARAMS = ConfigVar( - 'AUDIO_TTS_OPENAI_PARAMS', - 'audio.tts.openai.params', - audio_tts_openai_params, -) +AUDIO_TTS_OPENAI_PARAMS = audio_tts_openai_params -AUDIO_TTS_API_KEY = ConfigVar( - 'AUDIO_TTS_API_KEY', - 'audio.tts.api_key', - os.getenv('AUDIO_TTS_API_KEY', ''), -) +AUDIO_TTS_API_KEY = os.getenv('AUDIO_TTS_API_KEY', '') -AUDIO_TTS_ENGINE = ConfigVar( - 'AUDIO_TTS_ENGINE', - 'audio.tts.engine', - os.getenv('AUDIO_TTS_ENGINE', ''), -) +AUDIO_TTS_ENGINE = os.getenv('AUDIO_TTS_ENGINE', '') -AUDIO_TTS_MODEL = ConfigVar( - 'AUDIO_TTS_MODEL', - 'audio.tts.model', - os.getenv('AUDIO_TTS_MODEL', 'tts-1'), # OpenAI default model -) +AUDIO_TTS_MODEL = os.getenv('AUDIO_TTS_MODEL', 'tts-1') -AUDIO_TTS_VOICE = ConfigVar( - 'AUDIO_TTS_VOICE', - 'audio.tts.voice', - os.getenv('AUDIO_TTS_VOICE', 'alloy'), # OpenAI default voice -) +AUDIO_TTS_VOICE = os.getenv('AUDIO_TTS_VOICE', 'alloy') -AUDIO_TTS_SPLIT_ON = ConfigVar( - 'AUDIO_TTS_SPLIT_ON', - 'audio.tts.split_on', - os.getenv('AUDIO_TTS_SPLIT_ON', 'punctuation'), -) +AUDIO_TTS_SPLIT_ON = os.getenv('AUDIO_TTS_SPLIT_ON', 'punctuation') -AUDIO_TTS_AZURE_SPEECH_REGION = ConfigVar( - 'AUDIO_TTS_AZURE_SPEECH_REGION', - 'audio.tts.azure.speech_region', - os.getenv('AUDIO_TTS_AZURE_SPEECH_REGION', ''), -) +AUDIO_TTS_AZURE_SPEECH_REGION = os.getenv('AUDIO_TTS_AZURE_SPEECH_REGION', '') -AUDIO_TTS_AZURE_SPEECH_BASE_URL = ConfigVar( - 'AUDIO_TTS_AZURE_SPEECH_BASE_URL', - 'audio.tts.azure.speech_base_url', - os.getenv('AUDIO_TTS_AZURE_SPEECH_BASE_URL', ''), -) +AUDIO_TTS_AZURE_SPEECH_BASE_URL = os.getenv('AUDIO_TTS_AZURE_SPEECH_BASE_URL', '') -AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = ConfigVar( - 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', - 'audio.tts.azure.speech_output_format', - os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3'), -) +AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3') -AUDIO_TTS_MISTRAL_API_KEY = ConfigVar( - 'AUDIO_TTS_MISTRAL_API_KEY', - 'audio.tts.mistral.api_key', - os.getenv('AUDIO_TTS_MISTRAL_API_KEY', ''), -) +AUDIO_TTS_MISTRAL_API_KEY = os.getenv('AUDIO_TTS_MISTRAL_API_KEY', '') -AUDIO_TTS_MISTRAL_API_BASE_URL = ConfigVar( - 'AUDIO_TTS_MISTRAL_API_BASE_URL', - 'audio.tts.mistral.api_base_url', - os.getenv('AUDIO_TTS_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'), -) +AUDIO_TTS_MISTRAL_API_BASE_URL = os.getenv('AUDIO_TTS_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1') #################################### # WEBUI #################################### -WEBUI_URL = ConfigVar('WEBUI_URL', 'webui.url', os.getenv('WEBUI_URL', '')) +WEBUI_URL = os.getenv('WEBUI_URL', '') -ENABLE_SIGNUP = ConfigVar( - 'ENABLE_SIGNUP', - 'ui.enable_signup', - (False if not WEBUI_AUTH else os.getenv('ENABLE_SIGNUP', 'True').lower() == 'true'), -) +ENABLE_SIGNUP = False if not WEBUI_AUTH else os.getenv('ENABLE_SIGNUP', 'True').lower() == 'true' -ENABLE_LOGIN_FORM = ConfigVar( - 'ENABLE_LOGIN_FORM', - 'ui.enable_login_form', - os.getenv('ENABLE_LOGIN_FORM', 'True').lower() == 'true', -) +ENABLE_LOGIN_FORM = os.getenv('ENABLE_LOGIN_FORM', 'True').lower() == 'true' -ENABLE_PASSWORD_CHANGE_FORM = ConfigVar( - 'ENABLE_PASSWORD_CHANGE_FORM', - 'ui.enable_password_change_form', - os.getenv('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true', -) +ENABLE_PASSWORD_CHANGE_FORM = os.getenv('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true' ENABLE_PASSWORD_AUTH = os.getenv('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true' -DEFAULT_LOCALE = ConfigVar( - 'DEFAULT_LOCALE', - 'ui.default_locale', - os.getenv('DEFAULT_LOCALE', ''), -) +DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', '') -DEFAULT_MODELS = ConfigVar('DEFAULT_MODELS', 'ui.default_models', os.getenv('DEFAULT_MODELS', None)) +DEFAULT_MODELS = os.getenv('DEFAULT_MODELS', None) -DEFAULT_PINNED_MODELS = ConfigVar( - 'DEFAULT_PINNED_MODELS', - 'ui.default_pinned_models', - os.getenv('DEFAULT_PINNED_MODELS', None), -) +DEFAULT_PINNED_MODELS = os.getenv('DEFAULT_PINNED_MODELS', None) try: default_prompt_suggestions = json.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]')) @@ -2558,17 +1547,9 @@ def reachable(host: str, port: int) -> bool: }, ] -DEFAULT_PROMPT_SUGGESTIONS = ConfigVar( - 'DEFAULT_PROMPT_SUGGESTIONS', - 'ui.prompt_suggestions', - default_prompt_suggestions, -) +DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions -MODEL_ORDER_LIST = ConfigVar( - 'MODEL_ORDER_LIST', - 'ui.model_order_list', - [], -) +MODEL_ORDER_LIST = [] try: default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}')) @@ -2576,11 +1557,7 @@ def reachable(host: str, port: int) -> bool: log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}') default_model_metadata = {} -DEFAULT_MODEL_METADATA = ConfigVar( - 'DEFAULT_MODEL_METADATA', - 'models.default_metadata', - default_model_metadata, -) +DEFAULT_MODEL_METADATA = default_model_metadata try: default_model_params = json.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}')) @@ -2588,42 +1565,18 @@ def reachable(host: str, port: int) -> bool: log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}') default_model_params = {} -DEFAULT_MODEL_PARAMS = ConfigVar( - 'DEFAULT_MODEL_PARAMS', - 'models.default_params', - default_model_params, -) +DEFAULT_MODEL_PARAMS = default_model_params -DEFAULT_USER_ROLE = ConfigVar( - 'DEFAULT_USER_ROLE', - 'ui.default_user_role', - os.getenv('DEFAULT_USER_ROLE', 'pending'), -) +DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending') -DEFAULT_GROUP_ID = ConfigVar( - 'DEFAULT_GROUP_ID', - 'ui.default_group_id', - os.getenv('DEFAULT_GROUP_ID', ''), -) +DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '') -PENDING_USER_OVERLAY_TITLE = ConfigVar( - 'PENDING_USER_OVERLAY_TITLE', - 'ui.pending_user_overlay_title', - os.getenv('PENDING_USER_OVERLAY_TITLE', ''), -) +PENDING_USER_OVERLAY_TITLE = os.getenv('PENDING_USER_OVERLAY_TITLE', '') -PENDING_USER_OVERLAY_CONTENT = ConfigVar( - 'PENDING_USER_OVERLAY_CONTENT', - 'ui.pending_user_overlay_content', - os.getenv('PENDING_USER_OVERLAY_CONTENT', ''), -) +PENDING_USER_OVERLAY_CONTENT = os.getenv('PENDING_USER_OVERLAY_CONTENT', '') -RESPONSE_WATERMARK = ConfigVar( - 'RESPONSE_WATERMARK', - 'ui.watermark', - os.getenv('RESPONSE_WATERMARK', ''), -) +RESPONSE_WATERMARK = os.getenv('RESPONSE_WATERMARK', '') IFRAME_CSP = os.getenv('IFRAME_CSP', '') @@ -2892,83 +1845,35 @@ def reachable(host: str, port: int) -> bool: 'memories': USER_PERMISSIONS_FEATURES_MEMORIES, 'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS, 'calendar': USER_PERMISSIONS_FEATURES_CALENDAR, - 'user_webhooks': USER_PERMISSIONS_FEATURES_USER_WEBHOOKS, + 'webhooks': USER_PERMISSIONS_FEATURES_USER_WEBHOOKS, }, 'settings': { 'interface': USER_PERMISSIONS_SETTINGS_INTERFACE, }, } -USER_PERMISSIONS = ConfigVar( - 'USER_PERMISSIONS', - 'user.permissions', - DEFAULT_USER_PERMISSIONS, -) +USER_PERMISSIONS = DEFAULT_USER_PERMISSIONS -ENABLE_FOLDERS = ConfigVar( - 'ENABLE_FOLDERS', - 'folders.enable', - os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true', -) +ENABLE_FOLDERS = os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true' -FOLDER_MAX_FILE_COUNT = ConfigVar( - 'FOLDER_MAX_FILE_COUNT', - 'folders.max_file_count', - os.getenv('FOLDER_MAX_FILE_COUNT', ''), -) +FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '') -ENABLE_CHANNELS = ConfigVar( - 'ENABLE_CHANNELS', - 'channels.enable', - os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true', -) +ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true' -ENABLE_CALENDAR = ConfigVar( - 'ENABLE_CALENDAR', - 'calendar.enable', - os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true', -) +ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true' -ENABLE_AUTOMATIONS = ConfigVar( - 'ENABLE_AUTOMATIONS', - 'automations.enable', - os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true', -) +ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true' -AUTOMATION_MAX_COUNT = ConfigVar( - 'AUTOMATION_MAX_COUNT', - 'automations.max_count', - os.getenv('AUTOMATION_MAX_COUNT', ''), -) +AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '') -AUTOMATION_MIN_INTERVAL = ConfigVar( - 'AUTOMATION_MIN_INTERVAL', - 'automations.min_interval', - os.getenv('AUTOMATION_MIN_INTERVAL', ''), -) +AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '') -ENABLE_NOTES = ConfigVar( - 'ENABLE_NOTES', - 'notes.enable', - os.getenv('ENABLE_NOTES', 'True').lower() == 'true', -) +ENABLE_NOTES = os.getenv('ENABLE_NOTES', 'True').lower() == 'true' -ENABLE_USER_STATUS = ConfigVar( - 'ENABLE_USER_STATUS', - 'users.enable_status', - os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true', -) +ENABLE_USER_STATUS = os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true' -ENABLE_EVALUATION_ARENA_MODELS = ConfigVar( - 'ENABLE_EVALUATION_ARENA_MODELS', - 'evaluation.arena.enable', - os.getenv('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true', -) -EVALUATION_ARENA_MODELS = ConfigVar( - 'EVALUATION_ARENA_MODELS', - 'evaluation.arena.models', - [], -) +ENABLE_EVALUATION_ARENA_MODELS = os.getenv('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true' +EVALUATION_ARENA_MODELS = [] DEFAULT_ARENA_MODEL = { 'id': 'arena-model', @@ -2980,7 +1885,7 @@ def reachable(host: str, port: int) -> bool: }, } -WEBHOOK_URL = ConfigVar('WEBHOOK_URL', 'webhook_url', os.getenv('WEBHOOK_URL', '')) +WEBHOOK_URL = os.getenv('WEBHOOK_URL', '') ENABLE_ADMIN_EXPORT = os.getenv('ENABLE_ADMIN_EXPORT', 'True').lower() == 'true' @@ -2998,23 +1903,11 @@ def reachable(host: str, port: int) -> bool: ENABLE_ADMIN_ANALYTICS = os.getenv('ENABLE_ADMIN_ANALYTICS', 'True').lower() == 'true' -ENABLE_COMMUNITY_SHARING = ConfigVar( - 'ENABLE_COMMUNITY_SHARING', - 'ui.enable_community_sharing', - os.getenv('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true', -) +ENABLE_COMMUNITY_SHARING = os.getenv('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true' -ENABLE_MESSAGE_RATING = ConfigVar( - 'ENABLE_MESSAGE_RATING', - 'ui.enable_message_rating', - os.getenv('ENABLE_MESSAGE_RATING', 'True').lower() == 'true', -) +ENABLE_MESSAGE_RATING = os.getenv('ENABLE_MESSAGE_RATING', 'True').lower() == 'true' -ENABLE_USER_WEBHOOKS = ConfigVar( - 'ENABLE_USER_WEBHOOKS', - 'ui.enable_user_webhooks', - os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true', -) +ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true' # FastAPI / AnyIO settings THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None) @@ -3079,20 +1972,12 @@ class BannerModel(BaseModel): log.exception(f'Error loading WEBUI_BANNERS: {e}') banners = [] -WEBUI_BANNERS = ConfigVar('WEBUI_BANNERS', 'ui.banners', banners) +WEBUI_BANNERS = banners -SHOW_ADMIN_DETAILS = ConfigVar( - 'SHOW_ADMIN_DETAILS', - 'auth.admin.show', - os.getenv('SHOW_ADMIN_DETAILS', 'true').lower() == 'true', -) +SHOW_ADMIN_DETAILS = os.getenv('SHOW_ADMIN_DETAILS', 'true').lower() == 'true' -ADMIN_EMAIL = ConfigVar( - 'ADMIN_EMAIL', - 'auth.admin.email', - os.getenv('ADMIN_EMAIL', None), -) +ADMIN_EMAIL = os.getenv('ADMIN_EMAIL', None) #################################### @@ -3100,23 +1985,11 @@ class BannerModel(BaseModel): #################################### -TASK_MODEL = ConfigVar( - 'TASK_MODEL', - 'task.model.default', - os.getenv('TASK_MODEL', ''), -) +TASK_MODEL = os.getenv('TASK_MODEL', '') -TASK_MODEL_EXTERNAL = ConfigVar( - 'TASK_MODEL_EXTERNAL', - 'task.model.external', - os.getenv('TASK_MODEL_EXTERNAL', ''), -) +TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '') -TITLE_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'TITLE_GENERATION_PROMPT_TEMPLATE', - 'task.title.prompt_template', - os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', ''), -) +TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: Generate a concise, 3-5 word title with an emoji summarizing the chat history. @@ -3142,11 +2015,7 @@ class BannerModel(BaseModel): {{MESSAGES:END:2}} """ -TAGS_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'TAGS_GENERATION_PROMPT_TEMPLATE', - 'task.tags.prompt_template', - os.getenv('TAGS_GENERATION_PROMPT_TEMPLATE', ''), -) +TAGS_GENERATION_PROMPT_TEMPLATE = os.getenv('TAGS_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE = """### Task: Generate 1-3 broad tags categorizing the main themes of the chat history, along with 1-3 more specific subtopic tags. @@ -3166,11 +2035,7 @@ class BannerModel(BaseModel): {{MESSAGES:END:6}} """ -IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE', - 'task.image.prompt_template', - os.getenv('IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE', ''), -) +IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = os.getenv('IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task: Generate a detailed prompt for am image generation task based on the given language and context. Describe the image as if you were explaining it to someone who cannot see it. Include relevant details, colors, shapes, and any other important elements. @@ -3193,11 +2058,7 @@ class BannerModel(BaseModel): """ -FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE', - 'task.follow_up.prompt_template', - os.getenv('FOLLOW_UP_GENERATION_PROMPT_TEMPLATE', ''), -) +FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = os.getenv('FOLLOW_UP_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = """### Task: Suggest 3-5 relevant follow-up questions or prompts that the user might naturally ask next in this conversation as a **user**, based on the chat history, to help continue or deepen the discussion. @@ -3215,43 +2076,19 @@ class BannerModel(BaseModel): {{MESSAGES:END:6}} """ -ENABLE_FOLLOW_UP_GENERATION = ConfigVar( - 'ENABLE_FOLLOW_UP_GENERATION', - 'task.follow_up.enable', - os.getenv('ENABLE_FOLLOW_UP_GENERATION', 'True').lower() == 'true', -) +ENABLE_FOLLOW_UP_GENERATION = os.getenv('ENABLE_FOLLOW_UP_GENERATION', 'True').lower() == 'true' -ENABLE_TAGS_GENERATION = ConfigVar( - 'ENABLE_TAGS_GENERATION', - 'task.tags.enable', - os.getenv('ENABLE_TAGS_GENERATION', 'True').lower() == 'true', -) +ENABLE_TAGS_GENERATION = os.getenv('ENABLE_TAGS_GENERATION', 'True').lower() == 'true' -ENABLE_TITLE_GENERATION = ConfigVar( - 'ENABLE_TITLE_GENERATION', - 'task.title.enable', - os.getenv('ENABLE_TITLE_GENERATION', 'True').lower() == 'true', -) +ENABLE_TITLE_GENERATION = os.getenv('ENABLE_TITLE_GENERATION', 'True').lower() == 'true' -ENABLE_SEARCH_QUERY_GENERATION = ConfigVar( - 'ENABLE_SEARCH_QUERY_GENERATION', - 'task.query.search.enable', - os.getenv('ENABLE_SEARCH_QUERY_GENERATION', 'True').lower() == 'true', -) +ENABLE_SEARCH_QUERY_GENERATION = os.getenv('ENABLE_SEARCH_QUERY_GENERATION', 'True').lower() == 'true' -ENABLE_RETRIEVAL_QUERY_GENERATION = ConfigVar( - 'ENABLE_RETRIEVAL_QUERY_GENERATION', - 'task.query.retrieval.enable', - os.getenv('ENABLE_RETRIEVAL_QUERY_GENERATION', 'True').lower() == 'true', -) +ENABLE_RETRIEVAL_QUERY_GENERATION = os.getenv('ENABLE_RETRIEVAL_QUERY_GENERATION', 'True').lower() == 'true' -QUERY_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'QUERY_GENERATION_PROMPT_TEMPLATE', - 'task.query.prompt_template', - os.getenv('QUERY_GENERATION_PROMPT_TEMPLATE', ''), -) +QUERY_GENERATION_PROMPT_TEMPLATE = os.getenv('QUERY_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE = """### Task: Analyze the chat history to determine the necessity of generating search queries, in the given language. By default, **prioritize generating 1-3 broad and relevant search queries** unless it is absolutely certain that no additional information is required. The aim is to retrieve comprehensive, updated, and valuable information even with minimal uncertainty. If no search is unequivocally needed, return an empty list. @@ -3277,23 +2114,11 @@ class BannerModel(BaseModel): """ -ENABLE_AUTOCOMPLETE_GENERATION = ConfigVar( - 'ENABLE_AUTOCOMPLETE_GENERATION', - 'task.autocomplete.enable', - os.getenv('ENABLE_AUTOCOMPLETE_GENERATION', 'False').lower() == 'true', -) +ENABLE_AUTOCOMPLETE_GENERATION = os.getenv('ENABLE_AUTOCOMPLETE_GENERATION', 'False').lower() == 'true' -AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ConfigVar( - 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH', - 'task.autocomplete.input_max_length', - int(os.getenv('AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH', '-1')), -) +AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = int(os.getenv('AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH', '-1')) -AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = ConfigVar( - 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE', - 'task.autocomplete.prompt_template', - os.getenv('AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE', ''), -) +AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = os.getenv('AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -3339,17 +2164,9 @@ class BannerModel(BaseModel): """ -VOICE_MODE_PROMPT_TEMPLATE = ConfigVar( - 'VOICE_MODE_PROMPT_TEMPLATE', - 'task.voice.prompt_template', - os.getenv('VOICE_MODE_PROMPT_TEMPLATE', ''), -) +VOICE_MODE_PROMPT_TEMPLATE = os.getenv('VOICE_MODE_PROMPT_TEMPLATE', '') -ENABLE_VOICE_MODE_PROMPT = ConfigVar( - 'ENABLE_VOICE_MODE_PROMPT', - 'task.voice.prompt.enable', - os.getenv('ENABLE_VOICE_MODE_PROMPT', 'True').lower() == 'true', -) +ENABLE_VOICE_MODE_PROMPT = os.getenv('ENABLE_VOICE_MODE_PROMPT', 'True').lower() == 'true' DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant. @@ -3376,11 +2193,7 @@ class BannerModel(BaseModel): Stay consistent, helpful, and easy to listen to.""" -TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ConfigVar( - 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE', - 'task.tools.prompt_template', - os.getenv('TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE', ''), -) +TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = os.getenv('TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE', '') DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}} @@ -3422,31 +2235,15 @@ class BannerModel(BaseModel): # Auth #################################### -ENABLE_API_KEYS = ConfigVar( - 'ENABLE_API_KEYS', - 'auth.enable_api_keys', - os.getenv('ENABLE_API_KEYS', 'False').lower() == 'true', -) +ENABLE_API_KEYS = os.getenv('ENABLE_API_KEYS', 'False').lower() == 'true' -ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ConfigVar( - 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS', - 'auth.api_key.endpoint_restrictions', - os.getenv( - 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS', - os.getenv('ENABLE_API_KEY_ENDPOINT_RESTRICTIONS', 'False'), - ).lower() - == 'true', -) +ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = os.getenv( 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS', os.getenv('ENABLE_API_KEY_ENDPOINT_RESTRICTIONS', 'False'), ).lower() == 'true' -API_KEYS_ALLOWED_ENDPOINTS = ConfigVar( - 'API_KEYS_ALLOWED_ENDPOINTS', - 'auth.api_key.allowed_endpoints', - os.getenv('API_KEYS_ALLOWED_ENDPOINTS', os.getenv('API_KEY_ALLOWED_ENDPOINTS', '')), -) +API_KEYS_ALLOWED_ENDPOINTS = os.getenv('API_KEYS_ALLOWED_ENDPOINTS', os.getenv('API_KEY_ALLOWED_ENDPOINTS', '')) -JWT_EXPIRES_IN = ConfigVar('JWT_EXPIRES_IN', 'auth.jwt_expiry', os.getenv('JWT_EXPIRES_IN', '4w')) +JWT_EXPIRES_IN = os.getenv('JWT_EXPIRES_IN', '4w') -if JWT_EXPIRES_IN.value == '-1': +if JWT_EXPIRES_IN == '-1': log.warning( "⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n" ' See: https://docs.openwebui.com/reference/env-configuration\n' @@ -3456,57 +2253,25 @@ class BannerModel(BaseModel): # OAuth config #################################### -ENABLE_OAUTH_SIGNUP = ConfigVar( - 'ENABLE_OAUTH_SIGNUP', - 'oauth.enable_signup', - os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true', -) +ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true' -OAUTH_AUTO_REDIRECT = ConfigVar( - 'OAUTH_AUTO_REDIRECT', - 'oauth.auto_redirect', - os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true', -) +OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true' -OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = ConfigVar( - 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', - 'oauth.refresh_token_include_scope', - os.getenv('OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', 'False').lower() == 'true', -) +OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = os.getenv('OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', 'False').lower() == 'true' -OAUTH_MERGE_ACCOUNTS_BY_EMAIL = ConfigVar( - 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL', - 'oauth.merge_accounts_by_email', - os.getenv('OAUTH_MERGE_ACCOUNTS_BY_EMAIL', 'False').lower() == 'true', -) +OAUTH_MERGE_ACCOUNTS_BY_EMAIL = os.getenv('OAUTH_MERGE_ACCOUNTS_BY_EMAIL', 'False').lower() == 'true' OAUTH_PROVIDERS = {} -GOOGLE_CLIENT_ID = ConfigVar( - 'GOOGLE_CLIENT_ID', - 'oauth.google.client_id', - os.getenv('GOOGLE_CLIENT_ID', ''), -) +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', '') -GOOGLE_CLIENT_SECRET = ConfigVar( - 'GOOGLE_CLIENT_SECRET', - 'oauth.google.client_secret', - os.getenv('GOOGLE_CLIENT_SECRET', ''), -) +GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET', '') -GOOGLE_OAUTH_SCOPE = ConfigVar( - 'GOOGLE_OAUTH_SCOPE', - 'oauth.google.scope', - os.getenv('GOOGLE_OAUTH_SCOPE', 'openid email profile'), -) +GOOGLE_OAUTH_SCOPE = os.getenv('GOOGLE_OAUTH_SCOPE', 'openid email profile') -GOOGLE_REDIRECT_URI = ConfigVar( - 'GOOGLE_REDIRECT_URI', - 'oauth.google.redirect_uri', - os.getenv('GOOGLE_REDIRECT_URI', ''), -) +GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', '') GOOGLE_OAUTH_AUTHORIZE_PARAMS = {} _google_oauth_authorize_params = os.getenv('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '') @@ -3520,283 +2285,104 @@ class BannerModel(BaseModel): except (json.JSONDecodeError, TypeError): log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') -MICROSOFT_CLIENT_ID = ConfigVar( - 'MICROSOFT_CLIENT_ID', - 'oauth.microsoft.client_id', - os.getenv('MICROSOFT_CLIENT_ID', ''), -) +MICROSOFT_CLIENT_ID = os.getenv('MICROSOFT_CLIENT_ID', '') -MICROSOFT_CLIENT_SECRET = ConfigVar( - 'MICROSOFT_CLIENT_SECRET', - 'oauth.microsoft.client_secret', - os.getenv('MICROSOFT_CLIENT_SECRET', ''), -) +MICROSOFT_CLIENT_SECRET = os.getenv('MICROSOFT_CLIENT_SECRET', '') -MICROSOFT_CLIENT_TENANT_ID = ConfigVar( - 'MICROSOFT_CLIENT_TENANT_ID', - 'oauth.microsoft.tenant_id', - os.getenv('MICROSOFT_CLIENT_TENANT_ID', ''), -) +MICROSOFT_CLIENT_TENANT_ID = os.getenv('MICROSOFT_CLIENT_TENANT_ID', '') -MICROSOFT_CLIENT_LOGIN_BASE_URL = ConfigVar( - 'MICROSOFT_CLIENT_LOGIN_BASE_URL', - 'oauth.microsoft.login_base_url', - os.getenv('MICROSOFT_CLIENT_LOGIN_BASE_URL', 'https://login.microsoftonline.com'), -) +MICROSOFT_CLIENT_LOGIN_BASE_URL = os.getenv('MICROSOFT_CLIENT_LOGIN_BASE_URL', 'https://login.microsoftonline.com') -MICROSOFT_CLIENT_PICTURE_URL = ConfigVar( - 'MICROSOFT_CLIENT_PICTURE_URL', - 'oauth.microsoft.picture_url', - os.getenv( - 'MICROSOFT_CLIENT_PICTURE_URL', - 'https://graph.microsoft.com/v1.0/me/photo/$value', - ), -) +MICROSOFT_CLIENT_PICTURE_URL = os.getenv( 'MICROSOFT_CLIENT_PICTURE_URL', 'https://graph.microsoft.com/v1.0/me/photo/$value', ) -MICROSOFT_OAUTH_SCOPE = ConfigVar( - 'MICROSOFT_OAUTH_SCOPE', - 'oauth.microsoft.scope', - os.getenv('MICROSOFT_OAUTH_SCOPE', 'openid email profile'), -) +MICROSOFT_OAUTH_SCOPE = os.getenv('MICROSOFT_OAUTH_SCOPE', 'openid email profile') -MICROSOFT_REDIRECT_URI = ConfigVar( - 'MICROSOFT_REDIRECT_URI', - 'oauth.microsoft.redirect_uri', - os.getenv('MICROSOFT_REDIRECT_URI', ''), -) +MICROSOFT_REDIRECT_URI = os.getenv('MICROSOFT_REDIRECT_URI', '') -GITHUB_CLIENT_ID = ConfigVar( - 'GITHUB_CLIENT_ID', - 'oauth.github.client_id', - os.getenv('GITHUB_CLIENT_ID', ''), -) +GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '') -GITHUB_CLIENT_SECRET = ConfigVar( - 'GITHUB_CLIENT_SECRET', - 'oauth.github.client_secret', - os.getenv('GITHUB_CLIENT_SECRET', ''), -) +GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '') -GITHUB_CLIENT_SCOPE = ConfigVar( - 'GITHUB_CLIENT_SCOPE', - 'oauth.github.scope', - os.getenv('GITHUB_CLIENT_SCOPE', 'user:email'), -) +GITHUB_CLIENT_SCOPE = os.getenv('GITHUB_CLIENT_SCOPE', 'user:email') -GITHUB_CLIENT_REDIRECT_URI = ConfigVar( - 'GITHUB_CLIENT_REDIRECT_URI', - 'oauth.github.redirect_uri', - os.getenv('GITHUB_CLIENT_REDIRECT_URI', ''), -) +GITHUB_CLIENT_REDIRECT_URI = os.getenv('GITHUB_CLIENT_REDIRECT_URI', '') -OAUTH_CLIENT_ID = ConfigVar( - 'OAUTH_CLIENT_ID', - 'oauth.oidc.client_id', - os.getenv('OAUTH_CLIENT_ID', ''), -) +OAUTH_CLIENT_ID = os.getenv('OAUTH_CLIENT_ID', '') -OAUTH_CLIENT_SECRET = ConfigVar( - 'OAUTH_CLIENT_SECRET', - 'oauth.oidc.client_secret', - os.getenv('OAUTH_CLIENT_SECRET', ''), -) +OAUTH_CLIENT_SECRET = os.getenv('OAUTH_CLIENT_SECRET', '') -OPENID_PROVIDER_URL = ConfigVar( - 'OPENID_PROVIDER_URL', - 'oauth.oidc.provider_url', - os.getenv('OPENID_PROVIDER_URL', ''), -) +OPENID_PROVIDER_URL = os.getenv('OPENID_PROVIDER_URL', '') -OPENID_END_SESSION_ENDPOINT = ConfigVar( - 'OPENID_END_SESSION_ENDPOINT', - 'oauth.oidc.end_session_endpoint', - os.getenv('OPENID_END_SESSION_ENDPOINT', ''), -) +OPENID_END_SESSION_ENDPOINT = os.getenv('OPENID_END_SESSION_ENDPOINT', '') -OPENID_REDIRECT_URI = ConfigVar( - 'OPENID_REDIRECT_URI', - 'oauth.oidc.redirect_uri', - os.getenv('OPENID_REDIRECT_URI', ''), -) +OPENID_REDIRECT_URI = os.getenv('OPENID_REDIRECT_URI', '') -OAUTH_SCOPES = ConfigVar( - 'OAUTH_SCOPES', - 'oauth.oidc.scopes', - os.getenv('OAUTH_SCOPES', 'openid email profile'), -) +OAUTH_SCOPES = os.getenv('OAUTH_SCOPES', 'openid email profile') -OAUTH_TIMEOUT = ConfigVar( - 'OAUTH_TIMEOUT', - 'oauth.oidc.oauth_timeout', - os.getenv('OAUTH_TIMEOUT', ''), -) +OAUTH_TIMEOUT = os.getenv('OAUTH_TIMEOUT', '') -OAUTH_TOKEN_ENDPOINT_AUTH_METHOD = ConfigVar( - 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD', - 'oauth.oidc.token_endpoint_auth_method', - os.getenv('OAUTH_TOKEN_ENDPOINT_AUTH_METHOD', None), -) +OAUTH_TOKEN_ENDPOINT_AUTH_METHOD = os.getenv('OAUTH_TOKEN_ENDPOINT_AUTH_METHOD', None) -OAUTH_CODE_CHALLENGE_METHOD = ConfigVar( - 'OAUTH_CODE_CHALLENGE_METHOD', - 'oauth.oidc.code_challenge_method', - os.getenv('OAUTH_CODE_CHALLENGE_METHOD', None), -) +OAUTH_CODE_CHALLENGE_METHOD = os.getenv('OAUTH_CODE_CHALLENGE_METHOD', None) -OAUTH_PROVIDER_NAME = ConfigVar( - 'OAUTH_PROVIDER_NAME', - 'oauth.oidc.provider_name', - os.getenv('OAUTH_PROVIDER_NAME', 'SSO'), -) +OAUTH_PROVIDER_NAME = os.getenv('OAUTH_PROVIDER_NAME', 'SSO') -OAUTH_SUB_CLAIM = ConfigVar( - 'OAUTH_SUB_CLAIM', - 'oauth.oidc.sub_claim', - os.getenv('OAUTH_SUB_CLAIM', None), -) +OAUTH_SUB_CLAIM = os.getenv('OAUTH_SUB_CLAIM', None) -OAUTH_USERNAME_CLAIM = ConfigVar( - 'OAUTH_USERNAME_CLAIM', - 'oauth.oidc.username_claim', - os.getenv('OAUTH_USERNAME_CLAIM', 'name'), -) +OAUTH_USERNAME_CLAIM = os.getenv('OAUTH_USERNAME_CLAIM', 'name') -OAUTH_PICTURE_CLAIM = ConfigVar( - 'OAUTH_PICTURE_CLAIM', - 'oauth.oidc.avatar_claim', - os.getenv('OAUTH_PICTURE_CLAIM', 'picture'), -) +OAUTH_PICTURE_CLAIM = os.getenv('OAUTH_PICTURE_CLAIM', 'picture') -OAUTH_EMAIL_CLAIM = ConfigVar( - 'OAUTH_EMAIL_CLAIM', - 'oauth.oidc.email_claim', - os.getenv('OAUTH_EMAIL_CLAIM', 'email'), -) +OAUTH_EMAIL_CLAIM = os.getenv('OAUTH_EMAIL_CLAIM', 'email') -OAUTH_GROUPS_CLAIM = ConfigVar( - 'OAUTH_GROUPS_CLAIM', - 'oauth.oidc.group_claim', - os.getenv('OAUTH_GROUPS_CLAIM', os.getenv('OAUTH_GROUP_CLAIM', 'groups')), -) +OAUTH_GROUPS_CLAIM = os.getenv('OAUTH_GROUPS_CLAIM', os.getenv('OAUTH_GROUP_CLAIM', 'groups')) -FEISHU_CLIENT_ID = ConfigVar( - 'FEISHU_CLIENT_ID', - 'oauth.feishu.client_id', - os.getenv('FEISHU_CLIENT_ID', ''), -) +FEISHU_CLIENT_ID = os.getenv('FEISHU_CLIENT_ID', '') -FEISHU_CLIENT_SECRET = ConfigVar( - 'FEISHU_CLIENT_SECRET', - 'oauth.feishu.client_secret', - os.getenv('FEISHU_CLIENT_SECRET', ''), -) +FEISHU_CLIENT_SECRET = os.getenv('FEISHU_CLIENT_SECRET', '') -FEISHU_OAUTH_SCOPE = ConfigVar( - 'FEISHU_OAUTH_SCOPE', - 'oauth.feishu.scope', - os.getenv('FEISHU_OAUTH_SCOPE', 'contact:user.base:readonly'), -) +FEISHU_OAUTH_SCOPE = os.getenv('FEISHU_OAUTH_SCOPE', 'contact:user.base:readonly') -FEISHU_REDIRECT_URI = ConfigVar( - 'FEISHU_REDIRECT_URI', - 'oauth.feishu.redirect_uri', - os.getenv('FEISHU_REDIRECT_URI', ''), -) +FEISHU_REDIRECT_URI = os.getenv('FEISHU_REDIRECT_URI', '') -ENABLE_OAUTH_ROLE_MANAGEMENT = ConfigVar( - 'ENABLE_OAUTH_ROLE_MANAGEMENT', - 'oauth.enable_role_mapping', - os.getenv('ENABLE_OAUTH_ROLE_MANAGEMENT', 'False').lower() == 'true', -) +ENABLE_OAUTH_ROLE_MANAGEMENT = os.getenv('ENABLE_OAUTH_ROLE_MANAGEMENT', 'False').lower() == 'true' -ENABLE_OAUTH_GROUP_MANAGEMENT = ConfigVar( - 'ENABLE_OAUTH_GROUP_MANAGEMENT', - 'oauth.enable_group_mapping', - os.getenv('ENABLE_OAUTH_GROUP_MANAGEMENT', 'False').lower() == 'true', -) +ENABLE_OAUTH_GROUP_MANAGEMENT = os.getenv('ENABLE_OAUTH_GROUP_MANAGEMENT', 'False').lower() == 'true' -ENABLE_OAUTH_GROUP_CREATION = ConfigVar( - 'ENABLE_OAUTH_GROUP_CREATION', - 'oauth.enable_group_creation', - os.getenv('ENABLE_OAUTH_GROUP_CREATION', 'False').lower() == 'true', -) +ENABLE_OAUTH_GROUP_CREATION = os.getenv('ENABLE_OAUTH_GROUP_CREATION', 'False').lower() == 'true' oauth_group_default_share = os.getenv('OAUTH_GROUP_DEFAULT_SHARE', 'true').strip().lower() -OAUTH_GROUP_DEFAULT_SHARE = ConfigVar( - 'OAUTH_GROUP_DEFAULT_SHARE', - 'oauth.group_default_share', - ('members' if oauth_group_default_share == 'members' else oauth_group_default_share == 'true'), -) +OAUTH_GROUP_DEFAULT_SHARE = 'members' if oauth_group_default_share == 'members' else oauth_group_default_share == 'true' -OAUTH_BLOCKED_GROUPS = ConfigVar( - 'OAUTH_BLOCKED_GROUPS', - 'oauth.blocked_groups', - os.getenv('OAUTH_BLOCKED_GROUPS', '[]'), -) +OAUTH_BLOCKED_GROUPS = os.getenv('OAUTH_BLOCKED_GROUPS', '[]') OAUTH_GROUPS_SEPARATOR = os.getenv('OAUTH_GROUPS_SEPARATOR', ';') -OAUTH_ROLES_CLAIM = ConfigVar( - 'OAUTH_ROLES_CLAIM', - 'oauth.roles_claim', - os.getenv('OAUTH_ROLES_CLAIM', 'roles'), -) +OAUTH_ROLES_CLAIM = os.getenv('OAUTH_ROLES_CLAIM', 'roles') OAUTH_ROLES_SEPARATOR = os.getenv('OAUTH_ROLES_SEPARATOR', ',') -OAUTH_ALLOWED_ROLES = ConfigVar( - 'OAUTH_ALLOWED_ROLES', - 'oauth.allowed_roles', - [ - role.strip() - for role in os.getenv('OAUTH_ALLOWED_ROLES', f'user{OAUTH_ROLES_SEPARATOR}admin').split(OAUTH_ROLES_SEPARATOR) - if role - ], -) +OAUTH_ALLOWED_ROLES = [ role.strip() for role in os.getenv('OAUTH_ALLOWED_ROLES', f'user{OAUTH_ROLES_SEPARATOR}admin').split(OAUTH_ROLES_SEPARATOR) if role ] -OAUTH_ADMIN_ROLES = ConfigVar( - 'OAUTH_ADMIN_ROLES', - 'oauth.admin_roles', - [role.strip() for role in os.getenv('OAUTH_ADMIN_ROLES', 'admin').split(OAUTH_ROLES_SEPARATOR) if role], -) +OAUTH_ADMIN_ROLES = [role.strip() for role in os.getenv('OAUTH_ADMIN_ROLES', 'admin').split(OAUTH_ROLES_SEPARATOR) if role] -OAUTH_ALLOWED_DOMAINS = ConfigVar( - 'OAUTH_ALLOWED_DOMAINS', - 'oauth.allowed_domains', - [domain.strip() for domain in os.getenv('OAUTH_ALLOWED_DOMAINS', '*').split(',')], -) +OAUTH_ALLOWED_DOMAINS = [domain.strip() for domain in os.getenv('OAUTH_ALLOWED_DOMAINS', '*').split(',')] -OAUTH_UPDATE_PICTURE_ON_LOGIN = ConfigVar( - 'OAUTH_UPDATE_PICTURE_ON_LOGIN', - 'oauth.update_picture_on_login', - os.getenv('OAUTH_UPDATE_PICTURE_ON_LOGIN', 'False').lower() == 'true', -) +OAUTH_UPDATE_PICTURE_ON_LOGIN = os.getenv('OAUTH_UPDATE_PICTURE_ON_LOGIN', 'False').lower() == 'true' -OAUTH_UPDATE_NAME_ON_LOGIN = ConfigVar( - 'OAUTH_UPDATE_NAME_ON_LOGIN', - 'oauth.update_name_on_login', - os.getenv('OAUTH_UPDATE_NAME_ON_LOGIN', 'False').lower() == 'true', -) +OAUTH_UPDATE_NAME_ON_LOGIN = os.getenv('OAUTH_UPDATE_NAME_ON_LOGIN', 'False').lower() == 'true' -OAUTH_UPDATE_EMAIL_ON_LOGIN = ConfigVar( - 'OAUTH_UPDATE_EMAIL_ON_LOGIN', - 'oauth.update_email_on_login', - os.getenv('OAUTH_UPDATE_EMAIL_ON_LOGIN', 'False').lower() == 'true', -) +OAUTH_UPDATE_EMAIL_ON_LOGIN = os.getenv('OAUTH_UPDATE_EMAIL_ON_LOGIN', 'False').lower() == 'true' OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = ( os.getenv('OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID', 'False').lower() == 'true' ) -OAUTH_AUDIENCE = ConfigVar( - 'OAUTH_AUDIENCE', - 'oauth.audience', - os.getenv('OAUTH_AUDIENCE', ''), -) +OAUTH_AUDIENCE = os.getenv('OAUTH_AUDIENCE', '') OAUTH_AUTHORIZE_PARAMS = {} _oauth_authorize_params = os.getenv('OAUTH_AUTHORIZE_PARAMS', '') @@ -3813,19 +2399,19 @@ class BannerModel(BaseModel): def load_oauth_providers(): OAUTH_PROVIDERS.clear() - if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value: + if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: def google_oauth_register(oauth: OAuth): client = oauth.register( name='google', - client_id=GOOGLE_CLIENT_ID.value, - client_secret=GOOGLE_CLIENT_SECRET.value, + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={ - 'scope': GOOGLE_OAUTH_SCOPE.value, - **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), + 'scope': GOOGLE_OAUTH_SCOPE, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), }, - redirect_uri=GOOGLE_REDIRECT_URI.value, + redirect_uri=GOOGLE_REDIRECT_URI, **({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) return client @@ -3834,43 +2420,43 @@ def google_oauth_register(oauth: OAuth): 'register': google_oauth_register, } - if MICROSOFT_CLIENT_ID.value and MICROSOFT_CLIENT_SECRET.value and MICROSOFT_CLIENT_TENANT_ID.value: + if MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET and MICROSOFT_CLIENT_TENANT_ID: def microsoft_oauth_register(oauth: OAuth): client = oauth.register( name='microsoft', - client_id=MICROSOFT_CLIENT_ID.value, - client_secret=MICROSOFT_CLIENT_SECRET.value, - server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}', + client_id=MICROSOFT_CLIENT_ID, + client_secret=MICROSOFT_CLIENT_SECRET, + server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}', client_kwargs={ - 'scope': MICROSOFT_OAUTH_SCOPE.value, - **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), + 'scope': MICROSOFT_OAUTH_SCOPE, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), }, - redirect_uri=MICROSOFT_REDIRECT_URI.value, + redirect_uri=MICROSOFT_REDIRECT_URI, ) return client OAUTH_PROVIDERS['microsoft'] = { - 'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value, + 'picture_url': MICROSOFT_CLIENT_PICTURE_URL, 'register': microsoft_oauth_register, } - if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value: + if GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET: def github_oauth_register(oauth: OAuth): client = oauth.register( name='github', - client_id=GITHUB_CLIENT_ID.value, - client_secret=GITHUB_CLIENT_SECRET.value, + client_id=GITHUB_CLIENT_ID, + client_secret=GITHUB_CLIENT_SECRET, access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize', api_base_url='https://api.github.com', userinfo_endpoint='https://api.github.com/user', client_kwargs={ - 'scope': GITHUB_CLIENT_SCOPE.value, - **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), + 'scope': GITHUB_CLIENT_SCOPE, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), }, - redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value, + redirect_uri=GITHUB_CLIENT_REDIRECT_URI, ) return client @@ -3880,61 +2466,61 @@ def github_oauth_register(oauth: OAuth): } if ( - OAUTH_CLIENT_ID.value - and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value) - and OPENID_PROVIDER_URL.value + OAUTH_CLIENT_ID + and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) + and OPENID_PROVIDER_URL ): def oidc_oauth_register(oauth: OAuth): client_kwargs = { - 'scope': OAUTH_SCOPES.value, + 'scope': OAUTH_SCOPES, **( - {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value} - if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value + {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD} + if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD else {} ), - **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), } - if OAUTH_CODE_CHALLENGE_METHOD.value and OAUTH_CODE_CHALLENGE_METHOD.value == 'S256': + if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256': client_kwargs['code_challenge_method'] = 'S256' - elif OAUTH_CODE_CHALLENGE_METHOD.value: + elif OAUTH_CODE_CHALLENGE_METHOD: raise Exception( 'Code challenge methods other than "%s" not supported. Given: "%s"' - % ('S256', OAUTH_CODE_CHALLENGE_METHOD.value) + % ('S256', OAUTH_CODE_CHALLENGE_METHOD) ) client = oauth.register( name='oidc', - client_id=OAUTH_CLIENT_ID.value, - client_secret=OAUTH_CLIENT_SECRET.value, - server_metadata_url=OPENID_PROVIDER_URL.value, + client_id=OAUTH_CLIENT_ID, + client_secret=OAUTH_CLIENT_SECRET, + server_metadata_url=OPENID_PROVIDER_URL, client_kwargs=client_kwargs, - redirect_uri=OPENID_REDIRECT_URI.value, + redirect_uri=OPENID_REDIRECT_URI, ) return client OAUTH_PROVIDERS['oidc'] = { - 'name': OAUTH_PROVIDER_NAME.value, + 'name': OAUTH_PROVIDER_NAME, 'register': oidc_oauth_register, } - if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value: + if FEISHU_CLIENT_ID and FEISHU_CLIENT_SECRET: def feishu_oauth_register(oauth: OAuth): client = oauth.register( name='feishu', - client_id=FEISHU_CLIENT_ID.value, - client_secret=FEISHU_CLIENT_SECRET.value, + client_id=FEISHU_CLIENT_ID, + client_secret=FEISHU_CLIENT_SECRET, access_token_url='https://open.feishu.cn/open-apis/authen/v2/oauth/token', authorize_url='https://accounts.feishu.cn/open-apis/authen/v1/authorize', api_base_url='https://open.feishu.cn/open-apis', userinfo_endpoint='https://open.feishu.cn/open-apis/authen/v1/user_info', client_kwargs={ - 'scope': FEISHU_OAUTH_SCOPE.value, - **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), + 'scope': FEISHU_OAUTH_SCOPE, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), }, - redirect_uri=FEISHU_REDIRECT_URI.value, + redirect_uri=FEISHU_REDIRECT_URI, ) return client @@ -3944,16 +2530,16 @@ def feishu_oauth_register(oauth: OAuth): } configured_providers = [] - if GOOGLE_CLIENT_ID.value: + if GOOGLE_CLIENT_ID: configured_providers.append('Google') - if MICROSOFT_CLIENT_ID.value: + if MICROSOFT_CLIENT_ID: configured_providers.append('Microsoft') - if GITHUB_CLIENT_ID.value: + if GITHUB_CLIENT_ID: configured_providers.append('GitHub') - if FEISHU_CLIENT_ID.value: + if FEISHU_CLIENT_ID: configured_providers.append('Feishu') - if configured_providers and not OPENID_PROVIDER_URL.value and not OPENID_END_SESSION_ENDPOINT.value: + if configured_providers and not OPENID_PROVIDER_URL and not OPENID_END_SESSION_ENDPOINT: provider_list = ', '.join(configured_providers) log.warning( f'⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!' @@ -3970,92 +2556,409 @@ def feishu_oauth_register(oauth: OAuth): # LDAP #################################### -ENABLE_LDAP = ConfigVar( - 'ENABLE_LDAP', - 'ldap.enable', - os.getenv('ENABLE_LDAP', 'false').lower() == 'true', -) - -LDAP_SERVER_LABEL = ConfigVar( - 'LDAP_SERVER_LABEL', - 'ldap.server.label', - os.getenv('LDAP_SERVER_LABEL', 'LDAP Server'), -) - -LDAP_SERVER_HOST = ConfigVar( - 'LDAP_SERVER_HOST', - 'ldap.server.host', - os.getenv('LDAP_SERVER_HOST', 'localhost'), -) - -LDAP_SERVER_PORT = ConfigVar( - 'LDAP_SERVER_PORT', - 'ldap.server.port', - int(os.getenv('LDAP_SERVER_PORT', '389')), -) - -LDAP_ATTRIBUTE_FOR_MAIL = ConfigVar( - 'LDAP_ATTRIBUTE_FOR_MAIL', - 'ldap.server.attribute_for_mail', - os.getenv('LDAP_ATTRIBUTE_FOR_MAIL', 'mail'), -) - -LDAP_ATTRIBUTE_FOR_USERNAME = ConfigVar( - 'LDAP_ATTRIBUTE_FOR_USERNAME', - 'ldap.server.attribute_for_username', - os.getenv('LDAP_ATTRIBUTE_FOR_USERNAME', 'uid'), -) - -LDAP_APP_DN = ConfigVar('LDAP_APP_DN', 'ldap.server.app_dn', os.getenv('LDAP_APP_DN', '')) - -LDAP_APP_PASSWORD = ConfigVar( - 'LDAP_APP_PASSWORD', - 'ldap.server.app_password', - os.getenv('LDAP_APP_PASSWORD', ''), -) - -LDAP_SEARCH_BASE = ConfigVar('LDAP_SEARCH_BASE', 'ldap.server.users_dn', os.getenv('LDAP_SEARCH_BASE', '')) - -LDAP_SEARCH_FILTERS = ConfigVar( - 'LDAP_SEARCH_FILTER', - 'ldap.server.search_filter', - os.getenv('LDAP_SEARCH_FILTER', os.getenv('LDAP_SEARCH_FILTERS', '')), -) - -LDAP_USE_TLS = ConfigVar( - 'LDAP_USE_TLS', - 'ldap.server.use_tls', - os.getenv('LDAP_USE_TLS', 'True').lower() == 'true', -) - -LDAP_CA_CERT_FILE = ConfigVar( - 'LDAP_CA_CERT_FILE', - 'ldap.server.ca_cert_file', - os.getenv('LDAP_CA_CERT_FILE', ''), -) - -LDAP_VALIDATE_CERT = ConfigVar( - 'LDAP_VALIDATE_CERT', - 'ldap.server.validate_cert', - os.getenv('LDAP_VALIDATE_CERT', 'True').lower() == 'true', -) - -LDAP_CIPHERS = ConfigVar('LDAP_CIPHERS', 'ldap.server.ciphers', os.getenv('LDAP_CIPHERS', 'ALL')) +ENABLE_LDAP = os.getenv('ENABLE_LDAP', 'false').lower() == 'true' + +LDAP_SERVER_LABEL = os.getenv('LDAP_SERVER_LABEL', 'LDAP Server') + +LDAP_SERVER_HOST = os.getenv('LDAP_SERVER_HOST', 'localhost') + +LDAP_SERVER_PORT = int(os.getenv('LDAP_SERVER_PORT', '389')) + +LDAP_ATTRIBUTE_FOR_MAIL = os.getenv('LDAP_ATTRIBUTE_FOR_MAIL', 'mail') + +LDAP_ATTRIBUTE_FOR_USERNAME = os.getenv('LDAP_ATTRIBUTE_FOR_USERNAME', 'uid') + +LDAP_APP_DN = os.getenv('LDAP_APP_DN', '') + +LDAP_APP_PASSWORD = os.getenv('LDAP_APP_PASSWORD', '') + +LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE', '') + +LDAP_SEARCH_FILTERS = os.getenv('LDAP_SEARCH_FILTER', os.getenv('LDAP_SEARCH_FILTERS', '')) + +LDAP_USE_TLS = os.getenv('LDAP_USE_TLS', 'True').lower() == 'true' + +LDAP_CA_CERT_FILE = os.getenv('LDAP_CA_CERT_FILE', '') + +LDAP_VALIDATE_CERT = os.getenv('LDAP_VALIDATE_CERT', 'True').lower() == 'true' + +LDAP_CIPHERS = os.getenv('LDAP_CIPHERS', 'ALL') + +ENABLE_LDAP_GROUP_MANAGEMENT = os.getenv('ENABLE_LDAP_GROUP_MANAGEMENT', 'False').lower() == 'true' + +ENABLE_LDAP_GROUP_CREATION = os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lower() == 'true' + +LDAP_ATTRIBUTE_FOR_GROUPS = os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf') + +DEFAULT_CONFIG = { + 'direct.enable': ENABLE_DIRECT_CONNECTIONS, + 'ollama.enable': ENABLE_OLLAMA_API, + 'ollama.base_urls': OLLAMA_BASE_URLS, + 'ollama.api_configs': OLLAMA_API_CONFIGS, + 'openai.enable': ENABLE_OPENAI_API, + 'openai.api_keys': OPENAI_API_KEYS, + 'openai.api_base_urls': OPENAI_API_BASE_URLS, + 'openai.api_configs': OPENAI_API_CONFIGS, + 'models.base_models_cache': ENABLE_BASE_MODELS_CACHE, + 'tool_server.connections': TOOL_SERVER_CONNECTIONS, + 'oauth.client.timeout': OAUTH_CLIENT_TIMEOUT, + 'terminal_server.connections': TERMINAL_SERVER_CONNECTIONS, + 'code_execution.enable': ENABLE_CODE_EXECUTION, + 'code_execution.engine': CODE_EXECUTION_ENGINE, + 'code_execution.jupyter.url': CODE_EXECUTION_JUPYTER_URL, + 'code_execution.jupyter.auth': CODE_EXECUTION_JUPYTER_AUTH, + 'code_execution.jupyter.auth_token': CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + 'code_execution.jupyter.auth_password': CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + 'code_execution.jupyter.timeout': CODE_EXECUTION_JUPYTER_TIMEOUT, + 'code_interpreter.enable': ENABLE_CODE_INTERPRETER, + 'memories.enable': ENABLE_MEMORIES, + 'code_interpreter.engine': CODE_INTERPRETER_ENGINE, + 'code_interpreter.prompt_template': CODE_INTERPRETER_PROMPT_TEMPLATE, + 'code_interpreter.jupyter.url': CODE_INTERPRETER_JUPYTER_URL, + 'code_interpreter.jupyter.auth': CODE_INTERPRETER_JUPYTER_AUTH, + 'code_interpreter.jupyter.auth_token': CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, + 'code_interpreter.jupyter.auth_password': CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + 'code_interpreter.jupyter.timeout': CODE_INTERPRETER_JUPYTER_TIMEOUT, + 'google_drive.enable': ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'google_drive.client_id': GOOGLE_DRIVE_CLIENT_ID, + 'google_drive.api_key': GOOGLE_DRIVE_API_KEY, + 'onedrive.enable': ENABLE_ONEDRIVE_INTEGRATION, + 'onedrive.sharepoint_url': ONEDRIVE_SHAREPOINT_URL, + 'onedrive.sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID, + 'rag.content_extraction_engine': CONTENT_EXTRACTION_ENGINE, + 'rag.datalab_marker_api_key': DATALAB_MARKER_API_KEY, + 'rag.datalab_marker_api_base_url': DATALAB_MARKER_API_BASE_URL, + 'rag.datalab_marker_additional_config': DATALAB_MARKER_ADDITIONAL_CONFIG, + 'rag.datalab_marker_use_llm': DATALAB_MARKER_USE_LLM, + 'rag.datalab_marker_skip_cache': DATALAB_MARKER_SKIP_CACHE, + 'rag.datalab_marker_force_ocr': DATALAB_MARKER_FORCE_OCR, + 'rag.datalab_marker_paginate': DATALAB_MARKER_PAGINATE, + 'rag.datalab_marker_strip_existing_ocr': DATALAB_MARKER_STRIP_EXISTING_OCR, + 'rag.datalab_marker_disable_image_extraction': DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + 'rag.datalab_marker_format_lines': DATALAB_MARKER_FORMAT_LINES, + 'rag.datalab_marker_output_format': DATALAB_MARKER_OUTPUT_FORMAT, + 'rag.mineru_api_mode': MINERU_API_MODE, + 'rag.mineru_api_url': MINERU_API_URL, + 'rag.mineru_api_timeout': MINERU_API_TIMEOUT, + 'rag.mineru_api_key': MINERU_API_KEY, + 'rag.mineru_params': MINERU_PARAMS, + 'rag.mineru_file_extensions': MINERU_FILE_EXTENSIONS, + 'rag.external_document_loader_url': EXTERNAL_DOCUMENT_LOADER_URL, + 'rag.external_document_loader_api_key': EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'rag.tika_server_url': TIKA_SERVER_URL, + 'rag.docling_server_url': DOCLING_SERVER_URL, + 'rag.docling_api_key': DOCLING_API_KEY, + 'rag.docling_params': DOCLING_PARAMS, + 'rag.document_intelligence_endpoint': DOCUMENT_INTELLIGENCE_ENDPOINT, + 'rag.document_intelligence_key': DOCUMENT_INTELLIGENCE_KEY, + 'rag.document_intelligence_model': DOCUMENT_INTELLIGENCE_MODEL, + 'rag.mistral_ocr_api_base_url': MISTRAL_OCR_API_BASE_URL, + 'rag.mistral_ocr_api_key': MISTRAL_OCR_API_KEY, + 'rag.paddleocr_vl_base_url': PADDLEOCR_VL_BASE_URL, + 'rag.paddleocr_vl_token': PADDLEOCR_VL_TOKEN, + 'rag.bypass_embedding_and_retrieval': BYPASS_EMBEDDING_AND_RETRIEVAL, + 'rag.top_k': RAG_TOP_K, + 'rag.top_k_reranker': RAG_TOP_K_RERANKER, + 'rag.relevance_threshold': RAG_RELEVANCE_THRESHOLD, + 'rag.hybrid_bm25_weight': RAG_HYBRID_BM25_WEIGHT, + 'rag.enable_hybrid_search': ENABLE_RAG_HYBRID_SEARCH, + 'rag.enable_hybrid_search_enriched_texts': ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + 'rag.full_context': RAG_FULL_CONTEXT, + 'rag.file.max_count': RAG_FILE_MAX_COUNT, + 'rag.file.max_size': RAG_FILE_MAX_SIZE, + 'file.image_compression_width': FILE_IMAGE_COMPRESSION_WIDTH, + 'file.image_compression_height': FILE_IMAGE_COMPRESSION_HEIGHT, + 'rag.file.allowed_extensions': RAG_ALLOWED_FILE_EXTENSIONS, + 'rag.embedding_engine': RAG_EMBEDDING_ENGINE, + 'rag.pdf_extract_images': PDF_EXTRACT_IMAGES, + 'rag.pdf_loader_mode': PDF_LOADER_MODE, + 'rag.embedding_model': RAG_EMBEDDING_MODEL, + 'rag.embedding_batch_size': RAG_EMBEDDING_BATCH_SIZE, + 'rag.enable_async_embedding': ENABLE_ASYNC_EMBEDDING, + 'rag.embedding_concurrent_requests': RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'rag.reranking_engine': RAG_RERANKING_ENGINE, + 'rag.reranking_model': RAG_RERANKING_MODEL, + 'rag.reranking_batch_size': RAG_RERANKING_BATCH_SIZE, + 'rag.external_reranker_url': RAG_EXTERNAL_RERANKER_URL, + 'rag.external_reranker_api_key': RAG_EXTERNAL_RERANKER_API_KEY, + 'rag.external_reranker_timeout': RAG_EXTERNAL_RERANKER_TIMEOUT, + 'rag.text_splitter': RAG_TEXT_SPLITTER, + 'rag.enable_markdown_header_text_splitter': ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, + 'rag.tiktoken_encoding_name': TIKTOKEN_ENCODING_NAME, + 'rag.chunk_size': CHUNK_SIZE, + 'rag.chunk_min_size_target': CHUNK_MIN_SIZE_TARGET, + 'rag.chunk_overlap': CHUNK_OVERLAP, + 'rag.template': RAG_TEMPLATE, + 'rag.openai.api_base_url': RAG_OPENAI_API_BASE_URL, + 'rag.openai.api_key': RAG_OPENAI_API_KEY, + 'rag.azure_openai.base_url': RAG_AZURE_OPENAI_BASE_URL, + 'rag.azure_openai.api_key': RAG_AZURE_OPENAI_API_KEY, + 'rag.azure_openai.api_version': RAG_AZURE_OPENAI_API_VERSION, + 'rag.ollama.base_url': RAG_OLLAMA_BASE_URL, + 'rag.ollama.api_key': RAG_OLLAMA_API_KEY, + 'rag.youtube_loader_language': YOUTUBE_LOADER_LANGUAGE, + 'rag.youtube_loader_proxy_url': YOUTUBE_LOADER_PROXY_URL, + 'rag.web.search.enable': ENABLE_WEB_SEARCH, + 'rag.web.search.engine': WEB_SEARCH_ENGINE, + 'rag.web.search.bypass_embedding_and_retrieval': BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'rag.web.search.bypass_web_loader': BYPASS_WEB_SEARCH_WEB_LOADER, + 'rag.web.search.result_count': WEB_SEARCH_RESULT_COUNT, + 'rag.web.search.domain.filter_list': WEB_SEARCH_DOMAIN_FILTER_LIST, + 'rag.web.search.concurrent_requests': WEB_SEARCH_CONCURRENT_REQUESTS, + 'rag.web.fetch.max_content_length': WEB_FETCH_MAX_CONTENT_LENGTH, + 'rag.web.loader.engine': WEB_LOADER_ENGINE, + 'rag.web.loader.concurrent_requests': WEB_LOADER_CONCURRENT_REQUESTS, + 'rag.web.loader.timeout': WEB_LOADER_TIMEOUT, + 'rag.web.loader.ssl_verification': ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'rag.web.search.trust_env': WEB_SEARCH_TRUST_ENV, + 'rag.web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'rag.web.search.searxng_query_url': SEARXNG_QUERY_URL, + 'rag.web.search.searxng_language': SEARXNG_LANGUAGE, + 'rag.web.search.yacy_query_url': YACY_QUERY_URL, + 'rag.web.search.yacy_username': YACY_USERNAME, + 'rag.web.search.yacy_password': YACY_PASSWORD, + 'rag.web.search.google_pse_api_key': GOOGLE_PSE_API_KEY, + 'rag.web.search.google_pse_engine_id': GOOGLE_PSE_ENGINE_ID, + 'rag.web.search.brave_search_api_key': BRAVE_SEARCH_API_KEY, + 'rag.web.search.brave_search_context_tokens': BRAVE_SEARCH_CONTEXT_TOKENS, + 'rag.web.search.kagi_search_api_key': KAGI_SEARCH_API_KEY, + 'rag.web.search.mojeek_search_api_key': MOJEEK_SEARCH_API_KEY, + 'rag.web.search.bocha_search_api_key': BOCHA_SEARCH_API_KEY, + 'rag.web.search.serpstack_api_key': SERPSTACK_API_KEY, + 'rag.web.search.serpstack_https': SERPSTACK_HTTPS, + 'rag.web.search.serper_api_key': SERPER_API_KEY, + 'rag.web.search.serply_api_key': SERPLY_API_KEY, + 'rag.web.search.ddgs_backend': DDGS_BACKEND, + 'rag.web.search.jina_api_key': JINA_API_KEY, + 'rag.web.search.jina_api_base_url': JINA_API_BASE_URL, + 'rag.web.search.searchapi_api_key': SEARCHAPI_API_KEY, + 'rag.web.search.searchapi_engine': SEARCHAPI_ENGINE, + 'rag.web.search.serpapi_api_key': SERPAPI_API_KEY, + 'rag.web.search.serpapi_engine': SERPAPI_ENGINE, + 'rag.web.search.bing_search_v7_endpoint': BING_SEARCH_V7_ENDPOINT, + 'rag.web.search.bing_search_v7_subscription_key': BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'rag.web.search.azure_ai_search_api_key': AZURE_AI_SEARCH_API_KEY, + 'rag.web.search.azure_ai_search_endpoint': AZURE_AI_SEARCH_ENDPOINT, + 'rag.web.search.azure_ai_search_index_name': AZURE_AI_SEARCH_INDEX_NAME, + 'rag.web.search.exa_api_key': EXA_API_KEY, + 'rag.web.search.perplexity_api_key': PERPLEXITY_API_KEY, + 'rag.web.search.perplexity_model': PERPLEXITY_MODEL, + 'rag.web.search.perplexity_search_context_usage': PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'rag.web.search.perplexity_search_api_url': PERPLEXITY_SEARCH_API_URL, + 'rag.web.search.sougou_api_sid': SOUGOU_API_SID, + 'rag.web.search.sougou_api_sk': SOUGOU_API_SK, + 'rag.web.search.tavily_api_key': TAVILY_API_KEY, + 'rag.web.search.tavily_extract_depth': TAVILY_EXTRACT_DEPTH, + 'rag.web.loader.playwright_ws_url': PLAYWRIGHT_WS_URL, + 'rag.web.loader.playwright_timeout': PLAYWRIGHT_TIMEOUT, + 'rag.web.loader.firecrawl_api_key': FIRECRAWL_API_KEY, + 'rag.web.loader.firecrawl_api_url': FIRECRAWL_API_BASE_URL, + 'rag.web.loader.firecrawl_timeout': FIRECRAWL_TIMEOUT, + 'rag.web.search.external_web_search_url': EXTERNAL_WEB_SEARCH_URL, + 'rag.web.search.external_web_search_api_key': EXTERNAL_WEB_SEARCH_API_KEY, + 'rag.web.loader.external_web_loader_url': EXTERNAL_WEB_LOADER_URL, + 'rag.web.loader.external_web_loader_api_key': EXTERNAL_WEB_LOADER_API_KEY, + 'rag.web.search.yandex_web_search_url': YANDEX_WEB_SEARCH_URL, + 'rag.web.search.yandex_web_search_api_key': YANDEX_WEB_SEARCH_API_KEY, + 'rag.web.search.yandex_web_search_config': YANDEX_WEB_SEARCH_CONFIG, + 'rag.web.search.youcom_api_key': YOUCOM_API_KEY, + 'rag.web.search.linkup_api_key': LINKUP_API_KEY, + 'rag.web.search.linkup_search_params': LINKUP_SEARCH_PARAMS, + 'image_generation.enable': ENABLE_IMAGE_GENERATION, + 'image_generation.engine': IMAGE_GENERATION_ENGINE, + 'image_generation.model': IMAGE_GENERATION_MODEL, + 'image_generation.size': IMAGE_SIZE, + 'image_generation.steps': IMAGE_STEPS, + 'image_generation.prompt.enable': ENABLE_IMAGE_PROMPT_GENERATION, + 'image_generation.automatic1111.base_url': AUTOMATIC1111_BASE_URL, + 'image_generation.automatic1111.api_auth': AUTOMATIC1111_API_AUTH, + 'image_generation.automatic1111.api_params': AUTOMATIC1111_PARAMS, + 'image_generation.comfyui.base_url': COMFYUI_BASE_URL, + 'image_generation.comfyui.api_key': COMFYUI_API_KEY, + 'image_generation.comfyui.workflow': COMFYUI_WORKFLOW, + 'image_generation.comfyui.nodes': COMFYUI_WORKFLOW_NODES, + 'image_generation.openai.api_base_url': IMAGES_OPENAI_API_BASE_URL, + 'image_generation.openai.api_version': IMAGES_OPENAI_API_VERSION, + 'image_generation.openai.api_key': IMAGES_OPENAI_API_KEY, + 'image_generation.openai.params': IMAGES_OPENAI_API_PARAMS, + 'image_generation.gemini.api_base_url': IMAGES_GEMINI_API_BASE_URL, + 'image_generation.gemini.api_key': IMAGES_GEMINI_API_KEY, + 'image_generation.gemini.endpoint_method': IMAGES_GEMINI_ENDPOINT_METHOD, + 'images.edit.enable': ENABLE_IMAGE_EDIT, + 'images.edit.engine': IMAGE_EDIT_ENGINE, + 'images.edit.model': IMAGE_EDIT_MODEL, + 'images.edit.size': IMAGE_EDIT_SIZE, + 'images.edit.openai.api_base_url': IMAGES_EDIT_OPENAI_API_BASE_URL, + 'images.edit.openai.api_version': IMAGES_EDIT_OPENAI_API_VERSION, + 'images.edit.openai.api_key': IMAGES_EDIT_OPENAI_API_KEY, + 'images.edit.gemini.api_base_url': IMAGES_EDIT_GEMINI_API_BASE_URL, + 'images.edit.gemini.api_key': IMAGES_EDIT_GEMINI_API_KEY, + 'images.edit.comfyui.base_url': IMAGES_EDIT_COMFYUI_BASE_URL, + 'images.edit.comfyui.api_key': IMAGES_EDIT_COMFYUI_API_KEY, + 'images.edit.comfyui.workflow': IMAGES_EDIT_COMFYUI_WORKFLOW, + 'images.edit.comfyui.nodes': IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + 'audio.stt.whisper_model': WHISPER_MODEL, + 'audio.stt.deepgram.api_key': DEEPGRAM_API_KEY, + 'audio.stt.openai.api_base_url': AUDIO_STT_OPENAI_API_BASE_URL, + 'audio.stt.openai.api_key': AUDIO_STT_OPENAI_API_KEY, + 'audio.stt.engine': AUDIO_STT_ENGINE, + 'audio.stt.model': AUDIO_STT_MODEL, + 'audio.stt.supported_content_types': AUDIO_STT_SUPPORTED_CONTENT_TYPES, + 'audio.stt.allowed_extensions': AUDIO_STT_ALLOWED_EXTENSIONS, + 'audio.stt.azure.api_key': AUDIO_STT_AZURE_API_KEY, + 'audio.stt.azure.region': AUDIO_STT_AZURE_REGION, + 'audio.stt.azure.locales': AUDIO_STT_AZURE_LOCALES, + 'audio.stt.azure.base_url': AUDIO_STT_AZURE_BASE_URL, + 'audio.stt.azure.max_speakers': AUDIO_STT_AZURE_MAX_SPEAKERS, + 'audio.stt.mistral.api_key': AUDIO_STT_MISTRAL_API_KEY, + 'audio.stt.mistral.api_base_url': AUDIO_STT_MISTRAL_API_BASE_URL, + 'audio.stt.mistral.use_chat_completions': AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, + 'audio.tts.openai.api_base_url': AUDIO_TTS_OPENAI_API_BASE_URL, + 'audio.tts.openai.api_key': AUDIO_TTS_OPENAI_API_KEY, + 'audio.tts.openai.params': AUDIO_TTS_OPENAI_PARAMS, + 'audio.tts.api_key': AUDIO_TTS_API_KEY, + 'audio.tts.engine': AUDIO_TTS_ENGINE, + 'audio.tts.model': AUDIO_TTS_MODEL, + 'audio.tts.voice': AUDIO_TTS_VOICE, + 'audio.tts.split_on': AUDIO_TTS_SPLIT_ON, + 'audio.tts.azure.speech_region': AUDIO_TTS_AZURE_SPEECH_REGION, + 'audio.tts.azure.speech_base_url': AUDIO_TTS_AZURE_SPEECH_BASE_URL, + 'audio.tts.azure.speech_output_format': AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, + 'audio.tts.mistral.api_key': AUDIO_TTS_MISTRAL_API_KEY, + 'audio.tts.mistral.api_base_url': AUDIO_TTS_MISTRAL_API_BASE_URL, + 'webui.url': WEBUI_URL, + 'ui.enable_signup': ENABLE_SIGNUP, + 'ui.enable_login_form': ENABLE_LOGIN_FORM, + 'ui.enable_password_change_form': ENABLE_PASSWORD_CHANGE_FORM, + 'ui.default_locale': DEFAULT_LOCALE, + 'ui.default_models': DEFAULT_MODELS, + 'ui.default_pinned_models': DEFAULT_PINNED_MODELS, + 'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS, + 'ui.model_order_list': MODEL_ORDER_LIST, + 'models.default_metadata': DEFAULT_MODEL_METADATA, + 'models.default_params': DEFAULT_MODEL_PARAMS, + 'ui.default_user_role': DEFAULT_USER_ROLE, + 'ui.default_group_id': DEFAULT_GROUP_ID, + 'ui.pending_user_overlay_title': PENDING_USER_OVERLAY_TITLE, + 'ui.pending_user_overlay_content': PENDING_USER_OVERLAY_CONTENT, + 'ui.watermark': RESPONSE_WATERMARK, + 'user.permissions': USER_PERMISSIONS, + 'folders.enable': ENABLE_FOLDERS, + 'folders.max_file_count': FOLDER_MAX_FILE_COUNT, + 'channels.enable': ENABLE_CHANNELS, + 'calendar.enable': ENABLE_CALENDAR, + 'automations.enable': ENABLE_AUTOMATIONS, + 'automations.max_count': AUTOMATION_MAX_COUNT, + 'automations.min_interval': AUTOMATION_MIN_INTERVAL, + 'notes.enable': ENABLE_NOTES, + 'users.enable_status': ENABLE_USER_STATUS, + 'evaluation.arena.enable': ENABLE_EVALUATION_ARENA_MODELS, + 'evaluation.arena.models': EVALUATION_ARENA_MODELS, + 'webhook_url': WEBHOOK_URL, + 'ui.enable_community_sharing': ENABLE_COMMUNITY_SHARING, + 'ui.enable_message_rating': ENABLE_MESSAGE_RATING, + 'ui.enable_user_webhooks': ENABLE_USER_WEBHOOKS, + 'ui.banners': WEBUI_BANNERS, + 'auth.admin.show': SHOW_ADMIN_DETAILS, + 'auth.admin.email': ADMIN_EMAIL, + 'task.model.default': TASK_MODEL, + 'task.model.external': TASK_MODEL_EXTERNAL, + 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, + 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, + 'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, + 'task.follow_up.prompt_template': FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, + 'task.follow_up.enable': ENABLE_FOLLOW_UP_GENERATION, + 'task.tags.enable': ENABLE_TAGS_GENERATION, + 'task.title.enable': ENABLE_TITLE_GENERATION, + 'task.query.search.enable': ENABLE_SEARCH_QUERY_GENERATION, + 'task.query.retrieval.enable': ENABLE_RETRIEVAL_QUERY_GENERATION, + 'task.query.prompt_template': QUERY_GENERATION_PROMPT_TEMPLATE, + 'task.autocomplete.enable': ENABLE_AUTOCOMPLETE_GENERATION, + 'task.autocomplete.input_max_length': AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, + 'task.autocomplete.prompt_template': AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, + 'task.voice.prompt_template': VOICE_MODE_PROMPT_TEMPLATE, + 'task.voice.prompt.enable': ENABLE_VOICE_MODE_PROMPT, + 'task.tools.prompt_template': TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + 'auth.enable_api_keys': ENABLE_API_KEYS, + 'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, + 'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS, + 'auth.jwt_expiry': JWT_EXPIRES_IN, + 'oauth.enable_signup': ENABLE_OAUTH_SIGNUP, + 'oauth.auto_redirect': OAUTH_AUTO_REDIRECT, + 'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, + 'oauth.merge_accounts_by_email': OAUTH_MERGE_ACCOUNTS_BY_EMAIL, + 'oauth.google.client_id': GOOGLE_CLIENT_ID, + 'oauth.google.client_secret': GOOGLE_CLIENT_SECRET, + 'oauth.google.scope': GOOGLE_OAUTH_SCOPE, + 'oauth.google.redirect_uri': GOOGLE_REDIRECT_URI, + 'oauth.microsoft.client_id': MICROSOFT_CLIENT_ID, + 'oauth.microsoft.client_secret': MICROSOFT_CLIENT_SECRET, + 'oauth.microsoft.tenant_id': MICROSOFT_CLIENT_TENANT_ID, + 'oauth.microsoft.login_base_url': MICROSOFT_CLIENT_LOGIN_BASE_URL, + 'oauth.microsoft.picture_url': MICROSOFT_CLIENT_PICTURE_URL, + 'oauth.microsoft.scope': MICROSOFT_OAUTH_SCOPE, + 'oauth.microsoft.redirect_uri': MICROSOFT_REDIRECT_URI, + 'oauth.github.client_id': GITHUB_CLIENT_ID, + 'oauth.github.client_secret': GITHUB_CLIENT_SECRET, + 'oauth.github.scope': GITHUB_CLIENT_SCOPE, + 'oauth.github.redirect_uri': GITHUB_CLIENT_REDIRECT_URI, + 'oauth.client_id': OAUTH_CLIENT_ID, + 'oauth.client_secret': OAUTH_CLIENT_SECRET, + 'oauth.provider_url': OPENID_PROVIDER_URL, + 'oauth.end_session_endpoint': OPENID_END_SESSION_ENDPOINT, + 'oauth.redirect_uri': OPENID_REDIRECT_URI, + 'oauth.scopes': OAUTH_SCOPES, + 'oauth.timeout': OAUTH_TIMEOUT, + 'oauth.token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD, + 'oauth.code_challenge_method': OAUTH_CODE_CHALLENGE_METHOD, + 'oauth.provider_name': OAUTH_PROVIDER_NAME, + 'oauth.sub_claim': OAUTH_SUB_CLAIM, + 'oauth.username_claim': OAUTH_USERNAME_CLAIM, + 'oauth.picture_claim': OAUTH_PICTURE_CLAIM, + 'oauth.email_claim': OAUTH_EMAIL_CLAIM, + 'oauth.group_claim': OAUTH_GROUPS_CLAIM, + 'oauth.feishu.client_id': FEISHU_CLIENT_ID, + 'oauth.feishu.client_secret': FEISHU_CLIENT_SECRET, + 'oauth.feishu.scope': FEISHU_OAUTH_SCOPE, + 'oauth.feishu.redirect_uri': FEISHU_REDIRECT_URI, + 'oauth.enable_role_mapping': ENABLE_OAUTH_ROLE_MANAGEMENT, + 'oauth.enable_group_mapping': ENABLE_OAUTH_GROUP_MANAGEMENT, + 'oauth.enable_group_creation': ENABLE_OAUTH_GROUP_CREATION, + 'oauth.group_default_share': OAUTH_GROUP_DEFAULT_SHARE, + 'oauth.blocked_groups': OAUTH_BLOCKED_GROUPS, + 'oauth.roles_claim': OAUTH_ROLES_CLAIM, + 'oauth.allowed_roles': OAUTH_ALLOWED_ROLES, + 'oauth.admin_roles': OAUTH_ADMIN_ROLES, + 'oauth.allowed_domains': OAUTH_ALLOWED_DOMAINS, + 'oauth.update_picture_on_login': OAUTH_UPDATE_PICTURE_ON_LOGIN, + 'oauth.update_name_on_login': OAUTH_UPDATE_NAME_ON_LOGIN, + 'oauth.update_email_on_login': OAUTH_UPDATE_EMAIL_ON_LOGIN, + 'oauth.audience': OAUTH_AUDIENCE, + 'ldap.enable': ENABLE_LDAP, + 'ldap.server.label': LDAP_SERVER_LABEL, + 'ldap.server.host': LDAP_SERVER_HOST, + 'ldap.server.port': LDAP_SERVER_PORT, + 'ldap.server.attribute_for_mail': LDAP_ATTRIBUTE_FOR_MAIL, + 'ldap.server.attribute_for_username': LDAP_ATTRIBUTE_FOR_USERNAME, + 'ldap.server.app_dn': LDAP_APP_DN, + 'ldap.server.app_password': LDAP_APP_PASSWORD, + 'ldap.server.users_dn': LDAP_SEARCH_BASE, + 'ldap.server.search_filter': LDAP_SEARCH_FILTERS, + 'ldap.server.use_tls': LDAP_USE_TLS, + 'ldap.server.ca_cert_file': LDAP_CA_CERT_FILE, + 'ldap.server.validate_cert': LDAP_VALIDATE_CERT, + 'ldap.server.ciphers': LDAP_CIPHERS, + 'ldap.group.enable_management': ENABLE_LDAP_GROUP_MANAGEMENT, + 'ldap.group.enable_creation': ENABLE_LDAP_GROUP_CREATION, + 'ldap.server.attribute_for_groups': LDAP_ATTRIBUTE_FOR_GROUPS, +} -ENABLE_LDAP_GROUP_MANAGEMENT = ConfigVar( - 'ENABLE_LDAP_GROUP_MANAGEMENT', - 'ldap.group.enable_management', - os.getenv('ENABLE_LDAP_GROUP_MANAGEMENT', 'False').lower() == 'true', -) -ENABLE_LDAP_GROUP_CREATION = ConfigVar( - 'ENABLE_LDAP_GROUP_CREATION', - 'ldap.group.enable_creation', - os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lower() == 'true', -) +ENABLE_PERSISTENT_CONFIG = os.getenv('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true' +ENABLE_OAUTH_PERSISTENT_CONFIG = os.getenv('ENABLE_OAUTH_PERSISTENT_CONFIG', 'False').lower() == 'true' -LDAP_ATTRIBUTE_FOR_GROUPS = ConfigVar( - 'LDAP_ATTRIBUTE_FOR_GROUPS', - 'ldap.server.attribute_for_groups', - os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf'), +Config.configure( + defaults=DEFAULT_CONFIG, + enable_persistent=ENABLE_PERSISTENT_CONFIG, + enable_oauth_persistent=ENABLE_OAUTH_PERSISTENT_CONFIG, ) diff --git a/backend/open_webui/internal/config.py b/backend/open_webui/internal/config.py deleted file mode 100644 index 46f5b1b67da..00000000000 --- a/backend/open_webui/internal/config.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Database-backed configuration with environment variable defaults.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from datetime import datetime -from functools import reduce -from typing import Any, Optional, Union - -import redis -from open_webui.internal.db import Base, get_async_db, get_db -from open_webui.utils.redis import get_redis_connection -from sqlalchemy import JSON, Column, DateTime, Integer, func, select - -log = logging.getLogger(__name__) - - -# ── Model ──────────────────────────────────────────────────────────────────── - - -class ConfigTable(Base): - __tablename__ = 'config' - - id = Column(Integer, primary_key=True) - data = Column(JSON, nullable=False) - version = Column(Integer, nullable=False, default=0) - created_at = Column(DateTime, nullable=False, server_default=func.now()) - updated_at = Column(DateTime, nullable=True, onupdate=func.now()) - - -# ── Blob ───────────────────────────────────────────────────────────────────── - - -class ConfigState: - """In-memory mirror of the single-row config JSON blob.""" - - __slots__ = ('_data',) - - def __init__(self) -> None: - self._data: dict[str, Any] = {} - - @property - def snapshot(self) -> dict: - return self._data - - def read(self, path: str) -> Any: - return reduce( - lambda n, k: n.get(k) if isinstance(n, dict) else None, - path.split('.'), - self._data, - ) - - def write(self, path: str, value: Any) -> None: - keys = path.split('.') - reduce(lambda d, k: d.setdefault(k, {}), keys[:-1], self._data)[keys[-1]] = value - - def replace(self, data: dict) -> None: - self._data = data - - def load(self) -> dict: - with get_db() as db: - row = db.query(ConfigTable).order_by(ConfigTable.id.desc()).first() - self._data = row.data if row else {'version': 0, 'ui': {}} - return self._data - - def persist(self, data: dict | None = None) -> None: - if data is not None: - self._data = data - with get_db() as db: - row = db.query(ConfigTable).first() - if row is None: - db.add(ConfigTable(data=self._data, version=0)) - else: - row.data, row.updated_at = self._data, datetime.now() - db.add(row) - db.commit() - - async def persist_async(self, data: dict | None = None) -> None: - if data is not None: - self._data = data - async with get_async_db() as db: - result = await db.execute(select(ConfigTable).limit(1)) - row = result.scalars().first() - if row is None: - db.add(ConfigTable(data=self._data, version=0)) - else: - row.data, row.updated_at = self._data, datetime.now() - db.add(row) - await db.commit() - - def clear(self) -> None: - with get_db() as db: - db.query(ConfigTable).delete() - db.commit() - - async def clear_async(self) -> None: - from sqlalchemy import delete as sa_delete - - async with get_async_db() as db: - await db.execute(sa_delete(ConfigTable)) - await db.commit() - - -STATE = ConfigState() - - -# ── ConfigVar ────────────────────────────────────────────────────────────────── - - -_persist_enabled: bool = True -_oauth_persist_enabled: bool = False -_all_configs: list[ConfigVar] = [] - - -def initialize(*, enable_persistent: bool = True, enable_oauth_persistent: bool = False) -> dict: - global _persist_enabled, _oauth_persist_enabled - _persist_enabled = enable_persistent - _oauth_persist_enabled = enable_oauth_persistent - return STATE.load() - - -class ConfigVar: - __slots__ = ('env_name', 'config_path', 'env_value', 'config_value', 'value') - - def __init__(self, env_name: str, config_path: str, env_value: Any) -> None: - self.env_name = env_name - self.config_path = config_path - self.env_value = env_value - self.config_value = STATE.read(config_path) - - if self.config_value is not None and _persist_enabled: - if config_path.startswith('oauth.') and not _oauth_persist_enabled: - log.info("Skipping DB value for '%s' (OAuth persistence disabled)", env_name) - self.value = env_value - else: - log.info("'%s' loaded from database", env_name) - self.value = self.config_value - else: - self.value = env_value - - _all_configs.append(self) - - def __str__(self) -> str: - return str(self.value) - - def __repr__(self) -> str: - return f'' - - @property - def __dict__(self): # type: ignore[override] - raise TypeError(f"ConfigVar('{self.env_name}') cannot be cast to dict; use .value") - - def __getattribute__(self, item: str): - if item == '__dict__': - raise TypeError('ConfigVar cannot be cast to dict; use .value') - return super().__getattribute__(item) - - def refresh(self) -> None: - current = STATE.read(self.config_path) - if current is not None: - self.value = current - log.info('Refreshed %s → %s', self.env_name, self.value) - - def commit(self) -> None: - log.info("Persisting '%s'", self.env_name) - STATE.write(self.config_path, self.value) - self.config_value = self.value - STATE.persist() - - async def commit_async(self) -> None: - log.info("Persisting '%s'", self.env_name) - STATE.write(self.config_path, self.value) - self.config_value = self.value - await STATE.persist_async() - - -# ── AppConfig ────────────────────────────────────────────────────────── - - -class AppConfig: - """Attribute-style container for ConfigVars with optional Redis sync.""" - - def __init__( - self, - *, - redis_url: Optional[str] = None, - redis_sentinels: Optional[list] = None, - redis_cluster: bool = False, - redis_key_prefix: str = 'open-webui', - ) -> None: - super().__setattr__('_entries', {}) - super().__setattr__('_key_prefix', redis_key_prefix) - - # If sentinels weren't explicitly provided, read from env. - if redis_sentinels is None: - from open_webui.env import REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - from open_webui.utils.redis import get_sentinels_from_env - - redis_sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT) - - rc: Union[redis.Redis, redis.cluster.RedisCluster, None] = None - if redis_url: - rc = get_redis_connection(redis_url, redis_sentinels or [], redis_cluster, decode_responses=True) - super().__setattr__('_rc', rc) - - def __setattr__(self, name: str, value: Any) -> None: - entries: dict = super().__getattribute__('_entries') - - if isinstance(value, ConfigVar): - entries[name] = value - return - - entries[name].value = value - - try: - asyncio.get_running_loop().create_task(self._write_async(name)) - except RuntimeError: - entries[name].commit() - - rc = super().__getattribute__('_rc') - if rc and _persist_enabled: - prefix = super().__getattribute__('_key_prefix') - try: - rc.set(f'{prefix}:config:{name}', json.dumps(entries[name].value)) - except Exception as exc: - log.error("Redis write failed for '%s': %s", name, exc) - - async def _write_async(self, name: str) -> None: - try: - await self._entries[name].commit_async() - except Exception as exc: - log.error("Async persist failed for '%s': %s", name, exc) - - def __getattr__(self, name: str) -> Any: - entries = super().__getattribute__('_entries') - if name not in entries: - raise AttributeError(f"No config key '{name}'") - - rc = super().__getattribute__('_rc') - if rc and _persist_enabled: - prefix = super().__getattribute__('_key_prefix') - try: - raw = rc.get(f'{prefix}:config:{name}') - if raw is not None: - decoded = json.loads(raw) - if entries[name].value != decoded: - entries[name].value = decoded - log.info("Updated '%s' from Redis", name) - except Exception as exc: - log.error("Redis read failed for '%s': %s", name, exc) - - return entries[name].value - - def _sync_to_redis(self) -> None: - rc = super().__getattribute__('_rc') - if not rc or not _persist_enabled: - return - prefix = super().__getattribute__('_key_prefix') - for name, s in super().__getattribute__('_entries').items(): - try: - rc.set(f'{prefix}:config:{name}', json.dumps(s.value)) - except Exception as exc: - log.error("Redis sync failed for '%s': %s", name, exc) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index dcedf335a3f..9aacdfeef65 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1,42 +1,30 @@ from __future__ import annotations import asyncio -import inspect import json import logging import mimetypes import os -import random -import re -import shutil import sys import time from contextlib import asynccontextmanager -from typing import Optional -from urllib.parse import parse_qs, urlencode, urlparse from uuid import uuid4 import aiohttp import anyio.to_thread -from aiocache import cached from fastapi import ( - BackgroundTasks, Depends, FastAPI, - File, - Form, HTTPException, Request, - UploadFile, applications, status, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_swagger_ui_html -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from redis import Redis from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from starlette.datastructures import Headers @@ -53,372 +41,34 @@ from starsessions.stores.redis import RedisStore from open_webui.config import ( - ADMIN_EMAIL, - API_KEYS_ALLOWED_ENDPOINTS, - AUDIO_STT_ALLOWED_EXTENSIONS, - AUDIO_STT_AZURE_API_KEY, - AUDIO_STT_AZURE_BASE_URL, - AUDIO_STT_AZURE_LOCALES, - AUDIO_STT_AZURE_MAX_SPEAKERS, - AUDIO_STT_AZURE_REGION, - # Audio - AUDIO_STT_ENGINE, - AUDIO_STT_MISTRAL_API_BASE_URL, - AUDIO_STT_MISTRAL_API_KEY, - AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, - AUDIO_STT_MODEL, - AUDIO_STT_OPENAI_API_BASE_URL, - AUDIO_STT_OPENAI_API_KEY, - AUDIO_STT_SUPPORTED_CONTENT_TYPES, - AUDIO_TTS_API_KEY, - AUDIO_TTS_AZURE_SPEECH_BASE_URL, - AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, - AUDIO_TTS_AZURE_SPEECH_REGION, - AUDIO_TTS_ENGINE, - AUDIO_TTS_MISTRAL_API_BASE_URL, - AUDIO_TTS_MISTRAL_API_KEY, - AUDIO_TTS_MODEL, - AUDIO_TTS_OPENAI_API_BASE_URL, - AUDIO_TTS_OPENAI_API_KEY, - AUDIO_TTS_OPENAI_PARAMS, - AUDIO_TTS_SPLIT_ON, - AUDIO_TTS_VOICE, - AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, - AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, - # Image - AUTOMATIC1111_API_AUTH, - AUTOMATIC1111_BASE_URL, - AUTOMATIC1111_PARAMS, - AUTOMATION_MAX_COUNT, - AUTOMATION_MIN_INTERVAL, - BING_SEARCH_V7_ENDPOINT, - BING_SEARCH_V7_SUBSCRIPTION_KEY, - BOCHA_SEARCH_API_KEY, - BRAVE_SEARCH_API_KEY, - BRAVE_SEARCH_CONTEXT_TOKENS, BYPASS_ADMIN_ACCESS_CONTROL, - BYPASS_EMBEDDING_AND_RETRIEVAL, - BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - BYPASS_WEB_SEARCH_WEB_LOADER, CACHE_DIR, - CHUNK_MIN_SIZE_TARGET, - CHUNK_OVERLAP, - CHUNK_SIZE, - CODE_EXECUTION_ENGINE, - CODE_EXECUTION_JUPYTER_AUTH, - CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, - CODE_EXECUTION_JUPYTER_AUTH_TOKEN, - CODE_EXECUTION_JUPYTER_TIMEOUT, - CODE_EXECUTION_JUPYTER_URL, - CODE_INTERPRETER_ENGINE, - CODE_INTERPRETER_JUPYTER_AUTH, - CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, - CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, - CODE_INTERPRETER_JUPYTER_TIMEOUT, - CODE_INTERPRETER_JUPYTER_URL, - CODE_INTERPRETER_PROMPT_TEMPLATE, - COMFYUI_API_KEY, - COMFYUI_BASE_URL, - COMFYUI_WORKFLOW, - COMFYUI_WORKFLOW_NODES, - CONTENT_EXTRACTION_ENGINE, CORS_ALLOW_ORIGIN, - DATALAB_MARKER_ADDITIONAL_CONFIG, - DATALAB_MARKER_API_BASE_URL, - DATALAB_MARKER_API_KEY, - DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - DATALAB_MARKER_FORCE_OCR, - DATALAB_MARKER_FORMAT_LINES, - DATALAB_MARKER_OUTPUT_FORMAT, - DATALAB_MARKER_PAGINATE, - DATALAB_MARKER_SKIP_CACHE, - DATALAB_MARKER_STRIP_EXISTING_OCR, - DATALAB_MARKER_USE_LLM, - DDGS_BACKEND, - DEEPGRAM_API_KEY, - DEFAULT_ARENA_MODEL, - DEFAULT_GROUP_ID, DEFAULT_LOCALE, - DEFAULT_MODEL_METADATA, - DEFAULT_MODEL_PARAMS, - DEFAULT_MODELS, - DEFAULT_PINNED_MODELS, - DEFAULT_PROMPT_SUGGESTIONS, - DEFAULT_RAG_TEMPLATE, - DEFAULT_USER_ROLE, - DOCLING_API_KEY, - DOCLING_PARAMS, - DOCLING_SERVER_URL, - DOCUMENT_INTELLIGENCE_ENDPOINT, - DOCUMENT_INTELLIGENCE_KEY, - DOCUMENT_INTELLIGENCE_MODEL, ENABLE_ADMIN_ANALYTICS, # Admin ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT, - ENABLE_API_KEYS, - ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, - ENABLE_ASYNC_EMBEDDING, - ENABLE_AUTOCOMPLETE_GENERATION, - ENABLE_AUTOMATIONS, - # Model list - ENABLE_BASE_MODELS_CACHE, - ENABLE_CALENDAR, - ENABLE_CHANNELS, - # Code Execution - ENABLE_CODE_EXECUTION, - ENABLE_CODE_INTERPRETER, - ENABLE_COMMUNITY_SHARING, - # Direct Connections - ENABLE_DIRECT_CONNECTIONS, - ENABLE_EVALUATION_ARENA_MODELS, - ENABLE_FOLDERS, - ENABLE_FOLLOW_UP_GENERATION, - ENABLE_GOOGLE_DRIVE_INTEGRATION, - ENABLE_IMAGE_EDIT, - ENABLE_IMAGE_GENERATION, - ENABLE_IMAGE_PROMPT_GENERATION, - # WebUI (LDAP) - ENABLE_LDAP, - ENABLE_LDAP_GROUP_CREATION, - # LDAP Group Management - ENABLE_LDAP_GROUP_MANAGEMENT, - ENABLE_LOGIN_FORM, - ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, - ENABLE_MEMORIES, - ENABLE_MESSAGE_RATING, - ENABLE_NOTES, - # WebUI (OAuth) - ENABLE_OAUTH_ROLE_MANAGEMENT, - # Ollama - ENABLE_OLLAMA_API, ENABLE_ONEDRIVE_BUSINESS, - ENABLE_ONEDRIVE_INTEGRATION, ENABLE_ONEDRIVE_PERSONAL, # OpenAI - ENABLE_OPENAI_API, - ENABLE_PASSWORD_CHANGE_FORM, - ENABLE_RAG_HYBRID_SEARCH, - ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, - ENABLE_RAG_LOCAL_WEB_FETCH, - ENABLE_RETRIEVAL_QUERY_GENERATION, - ENABLE_SEARCH_QUERY_GENERATION, - ENABLE_SIGNUP, - ENABLE_TAGS_GENERATION, - ENABLE_TITLE_GENERATION, - ENABLE_USER_STATUS, - ENABLE_USER_WEBHOOKS, - ENABLE_VOICE_MODE_PROMPT, - ENABLE_WEB_LOADER_SSL_VERIFICATION, - # Retrieval (Web Search) - ENABLE_WEB_SEARCH, - # Misc ENV, - EVALUATION_ARENA_MODELS, - EXA_API_KEY, - EXTERNAL_DOCUMENT_LOADER_API_KEY, - EXTERNAL_DOCUMENT_LOADER_URL, - EXTERNAL_WEB_LOADER_API_KEY, - EXTERNAL_WEB_LOADER_URL, - EXTERNAL_WEB_SEARCH_API_KEY, - EXTERNAL_WEB_SEARCH_URL, - FILE_IMAGE_COMPRESSION_HEIGHT, - FILE_IMAGE_COMPRESSION_WIDTH, - FIRECRAWL_API_BASE_URL, - FIRECRAWL_API_KEY, - FIRECRAWL_TIMEOUT, - FOLDER_MAX_FILE_COUNT, - FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, FRONTEND_BUILD_DIR, GOOGLE_DRIVE_API_KEY, GOOGLE_DRIVE_CLIENT_ID, - GOOGLE_PSE_API_KEY, - GOOGLE_PSE_ENGINE_ID, IFRAME_CSP, - IMAGE_EDIT_ENGINE, - IMAGE_EDIT_MODEL, - IMAGE_EDIT_SIZE, - IMAGE_GENERATION_ENGINE, - IMAGE_GENERATION_MODEL, - IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, - IMAGE_SIZE, - IMAGE_STEPS, - IMAGES_EDIT_COMFYUI_API_KEY, - IMAGES_EDIT_COMFYUI_BASE_URL, - IMAGES_EDIT_COMFYUI_WORKFLOW, - IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, - IMAGES_EDIT_GEMINI_API_BASE_URL, - IMAGES_EDIT_GEMINI_API_KEY, - IMAGES_EDIT_OPENAI_API_BASE_URL, - IMAGES_EDIT_OPENAI_API_KEY, - IMAGES_EDIT_OPENAI_API_VERSION, - IMAGES_GEMINI_API_BASE_URL, - IMAGES_GEMINI_API_KEY, - IMAGES_GEMINI_ENDPOINT_METHOD, - IMAGES_OPENAI_API_BASE_URL, - IMAGES_OPENAI_API_KEY, - IMAGES_OPENAI_API_PARAMS, - IMAGES_OPENAI_API_VERSION, - JINA_API_BASE_URL, - JINA_API_KEY, - JWT_EXPIRES_IN, - KAGI_SEARCH_API_KEY, - LDAP_APP_DN, - LDAP_APP_PASSWORD, - LDAP_ATTRIBUTE_FOR_GROUPS, - LDAP_ATTRIBUTE_FOR_MAIL, - LDAP_ATTRIBUTE_FOR_USERNAME, - LDAP_CA_CERT_FILE, - LDAP_CIPHERS, - LDAP_SEARCH_BASE, - LDAP_SEARCH_FILTERS, - LDAP_SERVER_HOST, - LDAP_SERVER_LABEL, - LDAP_SERVER_PORT, - LDAP_USE_TLS, - LDAP_VALIDATE_CERT, - MINERU_API_KEY, - MINERU_API_MODE, - MINERU_API_TIMEOUT, - MINERU_API_URL, - MINERU_FILE_EXTENSIONS, - MINERU_PARAMS, - MISTRAL_OCR_API_BASE_URL, - MISTRAL_OCR_API_KEY, - MODEL_ORDER_LIST, - MOJEEK_SEARCH_API_KEY, - OAUTH_ADMIN_ROLES, - OAUTH_ALLOWED_ROLES, - OAUTH_AUTO_REDIRECT, - OAUTH_EMAIL_CLAIM, - OAUTH_PICTURE_CLAIM, OAUTH_PROVIDERS, - OAUTH_ROLES_CLAIM, - OAUTH_SUB_CLAIM, - OAUTH_USERNAME_CLAIM, - OLLAMA_API_CONFIGS, - OLLAMA_BASE_URLS, - OLLAMA_CLOUD_WEB_SEARCH_API_KEY, ONEDRIVE_CLIENT_ID_BUSINESS, ONEDRIVE_CLIENT_ID_PERSONAL, ONEDRIVE_SHAREPOINT_TENANT_ID, ONEDRIVE_SHAREPOINT_URL, - OPENAI_API_BASE_URLS, - OPENAI_API_CONFIGS, - OPENAI_API_KEYS, - PADDLEOCR_VL_BASE_URL, - PADDLEOCR_VL_TOKEN, - PDF_EXTRACT_IMAGES, - PDF_LOADER_MODE, - PENDING_USER_OVERLAY_CONTENT, - PENDING_USER_OVERLAY_TITLE, - PERPLEXITY_API_KEY, - PERPLEXITY_MODEL, - PERPLEXITY_SEARCH_API_URL, - PERPLEXITY_SEARCH_CONTEXT_USAGE, - PLAYWRIGHT_TIMEOUT, - PLAYWRIGHT_WS_URL, - QUERY_GENERATION_PROMPT_TEMPLATE, - RAG_ALLOWED_FILE_EXTENSIONS, - RAG_AZURE_OPENAI_API_KEY, - RAG_AZURE_OPENAI_API_VERSION, - RAG_AZURE_OPENAI_BASE_URL, - RAG_EMBEDDING_BATCH_SIZE, - RAG_EMBEDDING_CONCURRENT_REQUESTS, - RAG_EMBEDDING_ENGINE, - RAG_EMBEDDING_MODEL, - RAG_EMBEDDING_MODEL_AUTO_UPDATE, - RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, - RAG_EXTERNAL_RERANKER_API_KEY, - RAG_EXTERNAL_RERANKER_TIMEOUT, - RAG_EXTERNAL_RERANKER_URL, - RAG_FILE_MAX_COUNT, - RAG_FILE_MAX_SIZE, - RAG_FULL_CONTEXT, - RAG_HYBRID_BM25_WEIGHT, - RAG_OLLAMA_API_KEY, - RAG_OLLAMA_BASE_URL, - RAG_OPENAI_API_BASE_URL, - RAG_OPENAI_API_KEY, - RAG_RELEVANCE_THRESHOLD, - RAG_RERANKING_BATCH_SIZE, - RAG_RERANKING_ENGINE, - RAG_RERANKING_MODEL, - RAG_RERANKING_MODEL_AUTO_UPDATE, - RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, - # Retrieval - RAG_TEMPLATE, - RAG_TEXT_SPLITTER, - RAG_TOP_K, - RAG_TOP_K_RERANKER, - RESPONSE_WATERMARK, - SEARCHAPI_API_KEY, - SEARCHAPI_ENGINE, - SEARXNG_LANGUAGE, - SEARXNG_QUERY_URL, - SERPAPI_API_KEY, - SERPAPI_ENGINE, - SERPER_API_KEY, - SERPLY_API_KEY, - SERPSTACK_API_KEY, - SERPSTACK_HTTPS, - SHOW_ADMIN_DETAILS, - SOUGOU_API_SID, - SOUGOU_API_SK, STATIC_DIR, - TAGS_GENERATION_PROMPT_TEMPLATE, - # Tasks - TASK_MODEL, - TASK_MODEL_EXTERNAL, - TAVILY_API_KEY, - TAVILY_EXTRACT_DEPTH, - # Terminal Server - TERMINAL_SERVER_CONNECTIONS, - # Thread pool size for FastAPI/AnyIO THREAD_POOL_SIZE, - TIKA_SERVER_URL, - TIKTOKEN_ENCODING_NAME, - TITLE_GENERATION_PROMPT_TEMPLATE, - # Tool Server Configs - TOOL_SERVER_CONNECTIONS, - TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, - UPLOAD_DIR, - USER_PERMISSIONS, - VOICE_MODE_PROMPT_TEMPLATE, - WEB_FETCH_MAX_CONTENT_LENGTH, - WEB_LOADER_CONCURRENT_REQUESTS, - WEB_LOADER_ENGINE, - WEB_LOADER_TIMEOUT, - WEB_SEARCH_CONCURRENT_REQUESTS, - WEB_SEARCH_DOMAIN_FILTER_LIST, - WEB_SEARCH_ENGINE, - WEB_SEARCH_RESULT_COUNT, - WEB_SEARCH_TRUST_ENV, - WEBHOOK_URL, - # WebUI WEBUI_AUTH, - WEBUI_BANNERS, WEBUI_NAME, - WEBUI_URL, - WHISPER_LANGUAGE, - WHISPER_MODEL, - WHISPER_MODEL_AUTO_UPDATE, - WHISPER_MODEL_DIR, - WHISPER_VAD_FILTER, - YACY_PASSWORD, - YACY_QUERY_URL, - YACY_USERNAME, - YANDEX_WEB_SEARCH_API_KEY, - YANDEX_WEB_SEARCH_CONFIG, - YANDEX_WEB_SEARCH_URL, - YOUCOM_API_KEY, - LINKUP_API_KEY, - LINKUP_SEARCH_PARAMS, - YOUTUBE_LOADER_LANGUAGE, - YOUTUBE_LOADER_PROXY_URL, - AppConfig, async_reset_config, - reset_config, + import_legacy_config_json, + seed_registered_defaults, ) from open_webui.constants import ERROR_MESSAGES, TASKS from open_webui.env import ( @@ -433,6 +83,7 @@ ENABLE_COMPRESSION_MIDDLEWARE, ENABLE_CUSTOM_MODEL_FALLBACK, ENABLE_EASTER_EGGS, + EXTERNAL_PWA_MANIFEST_URL, # OAuth Back-Channel Logout ENABLE_OAUTH_BACKCHANNEL_LOGOUT, ENABLE_OTEL, @@ -443,14 +94,12 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE, ENABLE_VERSION_UPDATE_CHECK, ENABLE_WEBSOCKET_SUPPORT, - EXTERNAL_PWA_MANIFEST_URL, GLOBAL_LOG_LEVEL, INSTANCE_ID, LICENSE_KEY, LOG_FORMAT, MAX_BODY_LOG_SIZE, # Redis - REDIS_CLUSTER, REDIS_KEY_PREFIX, REDIS_URL, RESET_CONFIG_ON_START, @@ -461,22 +110,21 @@ WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_NAME, WEBUI_ADMIN_PASSWORD, - WEBUI_AUTH_SIGNOUT_REDIRECT_URL, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, - WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_BUILD_HASH, WEBUI_SECRET_KEY, WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, ) -from open_webui.internal.db import ScopedSession, engine, get_async_session +from open_webui.internal.db import engine, get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels from open_webui.models.chats import ChatForm, Chats +from open_webui.models.config import Config from open_webui.models.functions import Functions from open_webui.models.messages import Messages from open_webui.models.models import Models -from open_webui.models.users import UserModel, Users +from open_webui.models.users import Users from open_webui.routers import ( analytics, audio, @@ -583,7 +231,7 @@ resolve_oauth_client_info, ) from open_webui.utils.plugin import install_tool_and_function_dependencies -from open_webui.utils.redis import get_redis_client, get_redis_connection +from open_webui.utils.redis import get_redis_client from open_webui.utils.security_headers import SecurityHeadersMiddleware from open_webui.utils.session_pool import get_session from open_webui.utils.tools import set_terminal_servers, set_tool_servers @@ -644,6 +292,10 @@ async def lifespan(app: FastAPI): if RESET_CONFIG_ON_START: await async_reset_config() + await import_legacy_config_json() + await seed_registered_defaults() + await initialize_runtime_config(app) + if LICENSE_KEY: get_license_data(app, LICENSE_KEY) @@ -651,7 +303,7 @@ async def lifespan(app: FastAPI): if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD: if await create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME): # Disable signup since we now have an admin - app.state.config.ENABLE_SIGNUP = False + await Config.upsert({'ui.enable_signup': False}) if SAFE_MODE: await Functions.deactivate_all_functions() @@ -677,7 +329,7 @@ async def lifespan(app: FastAPI): asyncio.create_task(scheduler_worker_loop(app)) - if app.state.config.ENABLE_BASE_MODELS_CACHE: + if await Config.get('models.base_models_cache'): try: await get_all_models( Request( @@ -702,7 +354,7 @@ async def lifespan(app: FastAPI): log.warning(f'Failed to pre-fetch models at startup: {e}') # Pre-fetch tool server specs so the first request doesn't pay the latency cost - if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: + if len(await Config.get('tool_server.connections', []) or []) > 0: mock_request = Request( { 'type': 'http', @@ -766,15 +418,12 @@ async def lifespan(app: FastAPI): app.state.oauth_client_manager = oauth_client_manager app.state.instance_id = None -app.state.config = AppConfig( - redis_url=REDIS_URL, - redis_cluster=REDIS_CLUSTER, - redis_key_prefix=REDIS_KEY_PREFIX, -) app.state.redis = None app.state.WEBUI_NAME = WEBUI_NAME app.state.LICENSE_METADATA = None +app.state.USER_COUNT = None +app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL ######################################## @@ -796,9 +445,6 @@ async def lifespan(app: FastAPI): ######################################## -app.state.config.ENABLE_OLLAMA_API = ENABLE_OLLAMA_API -app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS -app.state.config.OLLAMA_API_CONFIGS = OLLAMA_API_CONFIGS app.state.OLLAMA_MODELS = {} @@ -808,10 +454,6 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.ENABLE_OPENAI_API = ENABLE_OPENAI_API -app.state.config.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS -app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS -app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS app.state.OPENAI_MODELS = {} @@ -821,7 +463,6 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS app.state.TOOL_SERVERS = [] ######################################## @@ -830,7 +471,6 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS app.state.TERMINAL_SERVERS = [] ######################################## @@ -839,7 +479,6 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS ######################################## # @@ -856,7 +495,6 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE app.state.BASE_MODELS = [] ######################################## @@ -865,352 +503,139 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.WEBUI_URL = WEBUI_URL -app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP -app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM -app.state.config.OAUTH_AUTO_REDIRECT = OAUTH_AUTO_REDIRECT -app.state.config.ENABLE_PASSWORD_CHANGE_FORM = ENABLE_PASSWORD_CHANGE_FORM - -app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS -app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS -app.state.config.API_KEYS_ALLOWED_ENDPOINTS = API_KEYS_ALLOWED_ENDPOINTS - -app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN - -app.state.config.SHOW_ADMIN_DETAILS = SHOW_ADMIN_DETAILS -app.state.config.ADMIN_EMAIL = ADMIN_EMAIL - - -app.state.config.DEFAULT_MODELS = DEFAULT_MODELS -app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS -app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST -app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA -app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS - - -app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS -app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE -app.state.config.DEFAULT_GROUP_ID = DEFAULT_GROUP_ID - -app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT -app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE - -app.state.config.RESPONSE_WATERMARK = RESPONSE_WATERMARK - -app.state.config.USER_PERMISSIONS = USER_PERMISSIONS -app.state.config.WEBHOOK_URL = WEBHOOK_URL -app.state.config.BANNERS = WEBUI_BANNERS - - -app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS -app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT -app.state.config.ENABLE_AUTOMATIONS = ENABLE_AUTOMATIONS -app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT -app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL -app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS -app.state.config.ENABLE_CALENDAR = ENABLE_CALENDAR -app.state.config.ENABLE_NOTES = ENABLE_NOTES -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_USER_STATUS = ENABLE_USER_STATUS - -app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS -app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS - -# Migrate legacy access_control → access_grants on boot -from open_webui.utils.access_control import has_permission, migrate_access_control - -connections = app.state.config.TOOL_SERVER_CONNECTIONS -if any('access_control' in c.get('config', {}) for c in connections): - for connection in connections: - migrate_access_control(connection.get('config', {})) - app.state.config.TOOL_SERVER_CONNECTIONS = connections - -arena_models = app.state.config.EVALUATION_ARENA_MODELS -if any('access_control' in m.get('meta', {}) for m in arena_models): - for model in arena_models: - migrate_access_control(model.get('meta', {})) - app.state.config.EVALUATION_ARENA_MODELS = arena_models - -app.state.config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM -app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM -app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM -app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM - -app.state.config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT -app.state.config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM -app.state.config.OAUTH_ALLOWED_ROLES = OAUTH_ALLOWED_ROLES -app.state.config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES - -app.state.config.ENABLE_LDAP = ENABLE_LDAP -app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL -app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST -app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT -app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = LDAP_ATTRIBUTE_FOR_MAIL -app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME -app.state.config.LDAP_APP_DN = LDAP_APP_DN -app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD -app.state.config.LDAP_SEARCH_BASE = LDAP_SEARCH_BASE -app.state.config.LDAP_SEARCH_FILTERS = LDAP_SEARCH_FILTERS -app.state.config.LDAP_USE_TLS = LDAP_USE_TLS -app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE -app.state.config.LDAP_VALIDATE_CERT = LDAP_VALIDATE_CERT -app.state.config.LDAP_CIPHERS = LDAP_CIPHERS - -# For LDAP Group Management -app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT = ENABLE_LDAP_GROUP_MANAGEMENT -app.state.config.ENABLE_LDAP_GROUP_CREATION = ENABLE_LDAP_GROUP_CREATION -app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS = LDAP_ATTRIBUTE_FOR_GROUPS - - -app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER -app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER -app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL -app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL -app.state.USER_COUNT = None -app.state.TOOLS = {} -app.state.TOOL_CONTENTS = {} -app.state.FUNCTIONS = {} -app.state.FUNCTION_CONTENTS = {} -######################################## -# -# RETRIEVAL -# -######################################## -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.HYBRID_BM25_WEIGHT = RAG_HYBRID_BM25_WEIGHT - - -app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS -app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE -app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT -app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = FILE_IMAGE_COMPRESSION_WIDTH -app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = FILE_IMAGE_COMPRESSION_HEIGHT - - -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_HYBRID_SEARCH_ENRICHED_TEXTS = ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS -app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERIFICATION - -app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE -app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY -app.state.config.DATALAB_MARKER_API_BASE_URL = DATALAB_MARKER_API_BASE_URL -app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = DATALAB_MARKER_ADDITIONAL_CONFIG -app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE -app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR -app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE -app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTING_OCR -app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION -app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES -app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM -app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT -app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL -app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY -app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL -app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL -app.state.config.DOCLING_API_KEY = DOCLING_API_KEY -app.state.config.DOCLING_PARAMS = DOCLING_PARAMS -app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT -app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY -app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL -app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL -app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY -app.state.config.PADDLEOCR_VL_BASE_URL = PADDLEOCR_VL_BASE_URL -app.state.config.PADDLEOCR_VL_TOKEN = PADDLEOCR_VL_TOKEN -app.state.config.MINERU_API_MODE = MINERU_API_MODE -app.state.config.MINERU_API_URL = MINERU_API_URL -app.state.config.MINERU_API_KEY = MINERU_API_KEY -app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT -app.state.config.MINERU_PARAMS = MINERU_PARAMS -app.state.config.MINERU_FILE_EXTENSIONS = MINERU_FILE_EXTENSIONS - -app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER -app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER - -app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME - -app.state.config.CHUNK_SIZE = CHUNK_SIZE -app.state.config.CHUNK_MIN_SIZE_TARGET = CHUNK_MIN_SIZE_TARGET -app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP - - -app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE -app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL -app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE -app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING -app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = RAG_EMBEDDING_CONCURRENT_REQUESTS - -app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE -app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL -app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL -app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY -app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT -app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE - -app.state.config.RAG_TEMPLATE = RAG_TEMPLATE - -app.state.config.RAG_OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL -app.state.config.RAG_OPENAI_API_KEY = RAG_OPENAI_API_KEY - -app.state.config.RAG_AZURE_OPENAI_BASE_URL = RAG_AZURE_OPENAI_BASE_URL -app.state.config.RAG_AZURE_OPENAI_API_KEY = RAG_AZURE_OPENAI_API_KEY -app.state.config.RAG_AZURE_OPENAI_API_VERSION = RAG_AZURE_OPENAI_API_VERSION - -app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL -app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY - -app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES -app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE - -app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE -app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL - - -app.state.config.ENABLE_WEB_SEARCH = ENABLE_WEB_SEARCH -app.state.config.WEB_SEARCH_ENGINE = WEB_SEARCH_ENGINE -app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = WEB_SEARCH_DOMAIN_FILTER_LIST -app.state.config.WEB_SEARCH_RESULT_COUNT = WEB_SEARCH_RESULT_COUNT -app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS -app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH = WEB_FETCH_MAX_CONTENT_LENGTH - -app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE -app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS -app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT - -app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV -app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL -app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER - -app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION -app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION - -app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY -app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL -app.state.config.SEARXNG_LANGUAGE = SEARXNG_LANGUAGE -app.state.config.YACY_QUERY_URL = YACY_QUERY_URL -app.state.config.YACY_USERNAME = YACY_USERNAME -app.state.config.YACY_PASSWORD = YACY_PASSWORD -app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY -app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID -app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY -app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = BRAVE_SEARCH_CONTEXT_TOKENS -app.state.config.KAGI_SEARCH_API_KEY = KAGI_SEARCH_API_KEY -app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY -app.state.config.BOCHA_SEARCH_API_KEY = BOCHA_SEARCH_API_KEY -app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY -app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS -app.state.config.SERPER_API_KEY = SERPER_API_KEY -app.state.config.SERPLY_API_KEY = SERPLY_API_KEY -app.state.config.DDGS_BACKEND = DDGS_BACKEND -app.state.config.TAVILY_API_KEY = TAVILY_API_KEY -app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY -app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE -app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY -app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE -app.state.config.JINA_API_KEY = JINA_API_KEY -app.state.config.JINA_API_BASE_URL = JINA_API_BASE_URL -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.PERPLEXITY_MODEL = PERPLEXITY_MODEL -app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = PERPLEXITY_SEARCH_CONTEXT_USAGE -app.state.config.PERPLEXITY_SEARCH_API_URL = PERPLEXITY_SEARCH_API_URL -app.state.config.SOUGOU_API_SID = SOUGOU_API_SID -app.state.config.SOUGOU_API_SK = SOUGOU_API_SK -app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL -app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY -app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL -app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY -app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL -app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY -app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG -app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY -app.state.config.LINKUP_API_KEY = LINKUP_API_KEY -app.state.config.LINKUP_SEARCH_PARAMS = LINKUP_SEARCH_PARAMS - - -app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL -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.FIRECRAWL_TIMEOUT = FIRECRAWL_TIMEOUT -app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH - -app.state.EMBEDDING_FUNCTION = None -app.state.RERANKING_FUNCTION = None -app.state.ef = None -app.state.rf = None - -app.state.YOUTUBE_LOADER_TRANSLATION = None -try: - app.state.ef = get_ef(app.state.config.RAG_EMBEDDING_ENGINE, app.state.config.RAG_EMBEDDING_MODEL) - if app.state.config.ENABLE_RAG_HYBRID_SEARCH and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: - app.state.rf = get_rf( - app.state.config.RAG_RERANKING_ENGINE, - app.state.config.RAG_RERANKING_MODEL, - app.state.config.RAG_EXTERNAL_RERANKER_URL, - app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + + + + + + +async def initialize_runtime_config(app: FastAPI): + # Migrate legacy access_control → access_grants on boot. + from open_webui.utils.access_control import migrate_access_control + + connections = await Config.get('tool_server.connections', []) or [] + if any('access_control' in c.get('config', {}) for c in connections): + for connection in connections: + migrate_access_control(connection.get('config', {})) + await Config.upsert({'tool_server.connections': connections}) + + for tool_server_connection in connections: + if tool_server_connection.get('type', 'openapi') == 'mcp': + server_id = tool_server_connection.get('info', {}).get('id') + auth_type = tool_server_connection.get('auth_type', 'none') + + if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): + try: + oauth_client_info = resolve_oauth_client_info(tool_server_connection) + app.state.oauth_client_manager.add_client( + f'mcp:{server_id}', + OAuthClientInformationFull(**oauth_client_info), + ) + except Exception as e: + log.error(f'Error adding OAuth client for MCP tool server {server_id}: {e}') + + arena_models = await Config.get('evaluation.arena.models', []) or [] + if any('access_control' in m.get('meta', {}) for m in arena_models): + for model in arena_models: + migrate_access_control(model.get('meta', {})) + await Config.upsert({'evaluation.arena.models': arena_models}) + + app.state.EMBEDDING_FUNCTION = None + app.state.RERANKING_FUNCTION = None + app.state.ef = None + app.state.rf = None + app.state.YOUTUBE_LOADER_TRANSLATION = None + + try: + rag_config = await Config.get_many( + 'rag.embedding_engine', + 'rag.embedding_model', + 'rag.enable_hybrid_search', + 'rag.bypass_embedding_and_retrieval', + 'rag.reranking_engine', + 'rag.reranking_model', + 'rag.external_reranker_url', + 'rag.external_reranker_api_key', + 'rag.external_reranker_timeout', ) - else: + app.state.ef = get_ef(rag_config.get('rag.embedding_engine'), rag_config.get('rag.embedding_model')) + if rag_config.get('rag.enable_hybrid_search') and not rag_config.get('rag.bypass_embedding_and_retrieval'): + app.state.rf = get_rf( + rag_config.get('rag.reranking_engine'), + rag_config.get('rag.reranking_model'), + rag_config.get('rag.external_reranker_url'), + rag_config.get('rag.external_reranker_api_key'), + rag_config.get('rag.external_reranker_timeout'), + ) + else: + app.state.rf = None + except Exception as e: + log.error(f'Error updating models: {e}') app.state.rf = None -except Exception as e: - log.error(f'Error updating models: {e}') - pass - - -app.state.EMBEDDING_FUNCTION = get_embedding_function( - app.state.config.RAG_EMBEDDING_ENGINE, - app.state.config.RAG_EMBEDDING_MODEL, - embedding_function=app.state.ef, - url=( - app.state.config.RAG_OPENAI_API_BASE_URL - if app.state.config.RAG_EMBEDDING_ENGINE == 'openai' - else ( - app.state.config.RAG_OLLAMA_BASE_URL - if app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else app.state.config.RAG_AZURE_OPENAI_BASE_URL - ) - ), - key=( - app.state.config.RAG_OPENAI_API_KEY - if app.state.config.RAG_EMBEDDING_ENGINE == 'openai' - else ( - app.state.config.RAG_OLLAMA_API_KEY - if app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else app.state.config.RAG_AZURE_OPENAI_API_KEY - ) - ), - embedding_batch_size=app.state.config.RAG_EMBEDDING_BATCH_SIZE, - azure_api_version=( - app.state.config.RAG_AZURE_OPENAI_API_VERSION - if app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' - else None - ), - enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING, - concurrent_requests=app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, -) -app.state.RERANKING_FUNCTION = get_reranking_function( - app.state.config.RAG_RERANKING_ENGINE, - app.state.config.RAG_RERANKING_MODEL, - reranking_function=app.state.rf, - reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE, -) + rag_config = await Config.get_many( + 'rag.embedding_engine', + 'rag.embedding_model', + 'rag.openai.api_base_url', + 'rag.ollama.base_url', + 'rag.azure_openai.base_url', + 'rag.openai.api_key', + 'rag.ollama.api_key', + 'rag.azure_openai.api_key', + 'rag.embedding_batch_size', + 'rag.azure_openai.api_version', + 'rag.enable_async_embedding', + 'rag.embedding_concurrent_requests', + 'rag.reranking_engine', + 'rag.reranking_model', + 'rag.reranking_batch_size', + ) + embedding_engine = rag_config.get('rag.embedding_engine') + app.state.EMBEDDING_FUNCTION = get_embedding_function( + embedding_engine, + rag_config.get('rag.embedding_model'), + embedding_function=app.state.ef, + url=( + rag_config.get('rag.openai.api_base_url') + if embedding_engine == 'openai' + else ( + rag_config.get('rag.ollama.base_url') + if embedding_engine == 'ollama' + else rag_config.get('rag.azure_openai.base_url') + ) + ), + key=( + rag_config.get('rag.openai.api_key') + if embedding_engine == 'openai' + else ( + rag_config.get('rag.ollama.api_key') + if embedding_engine == 'ollama' + else rag_config.get('rag.azure_openai.api_key') + ) + ), + embedding_batch_size=rag_config.get('rag.embedding_batch_size'), + azure_api_version=( + rag_config.get('rag.azure_openai.api_version') if embedding_engine == 'azure_openai' else None + ), + enable_async=rag_config.get('rag.enable_async_embedding'), + concurrent_requests=rag_config.get('rag.embedding_concurrent_requests'), + ) + + app.state.RERANKING_FUNCTION = get_reranking_function( + rag_config.get('rag.reranking_engine'), + rag_config.get('rag.reranking_model'), + reranking_function=app.state.rf, + reranking_batch_size=rag_config.get('rag.reranking_batch_size'), + ) + ######################################## # @@ -1218,23 +643,8 @@ async def lifespan(app: FastAPI): # ######################################## -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 -app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = CODE_EXECUTION_JUPYTER_AUTH_TOKEN -app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = CODE_EXECUTION_JUPYTER_AUTH_PASSWORD -app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = CODE_EXECUTION_JUPYTER_TIMEOUT -app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER -app.state.config.CODE_INTERPRETER_ENGINE = CODE_INTERPRETER_ENGINE -app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = CODE_INTERPRETER_PROMPT_TEMPLATE -app.state.config.CODE_INTERPRETER_JUPYTER_URL = CODE_INTERPRETER_JUPYTER_URL -app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = CODE_INTERPRETER_JUPYTER_AUTH -app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = CODE_INTERPRETER_JUPYTER_AUTH_TOKEN -app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD -app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIMEOUT ######################################## # @@ -1242,47 +652,13 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE -app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION -app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION -app.state.config.ENABLE_MEMORIES = ENABLE_MEMORIES - -app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL -app.state.config.IMAGE_SIZE = IMAGE_SIZE -app.state.config.IMAGE_STEPS = IMAGE_STEPS - -app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL -app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION -app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY -app.state.config.IMAGES_OPENAI_API_PARAMS = IMAGES_OPENAI_API_PARAMS - -app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL -app.state.config.IMAGES_GEMINI_API_KEY = IMAGES_GEMINI_API_KEY -app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = IMAGES_GEMINI_ENDPOINT_METHOD - -app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL -app.state.config.AUTOMATIC1111_API_AUTH = AUTOMATIC1111_API_AUTH -app.state.config.AUTOMATIC1111_PARAMS = AUTOMATIC1111_PARAMS - -app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL -app.state.config.COMFYUI_API_KEY = COMFYUI_API_KEY -app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW -app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES - - -app.state.config.ENABLE_IMAGE_EDIT = ENABLE_IMAGE_EDIT -app.state.config.IMAGE_EDIT_ENGINE = IMAGE_EDIT_ENGINE -app.state.config.IMAGE_EDIT_MODEL = IMAGE_EDIT_MODEL -app.state.config.IMAGE_EDIT_SIZE = IMAGE_EDIT_SIZE -app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = IMAGES_EDIT_OPENAI_API_BASE_URL -app.state.config.IMAGES_EDIT_OPENAI_API_KEY = IMAGES_EDIT_OPENAI_API_KEY -app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = IMAGES_EDIT_OPENAI_API_VERSION -app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = IMAGES_EDIT_GEMINI_API_BASE_URL -app.state.config.IMAGES_EDIT_GEMINI_API_KEY = IMAGES_EDIT_GEMINI_API_KEY -app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = IMAGES_EDIT_COMFYUI_BASE_URL -app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = IMAGES_EDIT_COMFYUI_API_KEY -app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = IMAGES_EDIT_COMFYUI_WORKFLOW -app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFLOW_NODES + + + + + + + ######################################## @@ -1291,46 +667,17 @@ async def lifespan(app: FastAPI): # ######################################## -app.state.config.STT_ENGINE = AUDIO_STT_ENGINE -app.state.config.STT_MODEL = AUDIO_STT_MODEL -app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES -app.state.config.STT_ALLOWED_EXTENSIONS = AUDIO_STT_ALLOWED_EXTENSIONS -app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL -app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY -app.state.config.WHISPER_MODEL = WHISPER_MODEL -app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY -app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY -app.state.config.AUDIO_STT_AZURE_REGION = AUDIO_STT_AZURE_REGION -app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES -app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL -app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS -app.state.config.AUDIO_STT_MISTRAL_API_KEY = AUDIO_STT_MISTRAL_API_KEY -app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = AUDIO_STT_MISTRAL_API_BASE_URL -app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS -app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE -app.state.config.TTS_MODEL = AUDIO_TTS_MODEL -app.state.config.TTS_VOICE = AUDIO_TTS_VOICE -app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL -app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY -app.state.config.TTS_OPENAI_PARAMS = AUDIO_TTS_OPENAI_PARAMS -app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY -app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON -app.state.config.TTS_AZURE_SPEECH_REGION = AUDIO_TTS_AZURE_SPEECH_REGION -app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL -app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT -app.state.config.TTS_MISTRAL_API_KEY = AUDIO_TTS_MISTRAL_API_KEY -app.state.config.TTS_MISTRAL_API_BASE_URL = AUDIO_TTS_MISTRAL_API_BASE_URL app.state.faster_whisper_model = None @@ -1345,29 +692,11 @@ async def lifespan(app: FastAPI): ######################################## -app.state.config.TASK_MODEL = TASK_MODEL -app.state.config.TASK_MODEL_EXTERNAL = TASK_MODEL_EXTERNAL -app.state.config.ENABLE_SEARCH_QUERY_GENERATION = ENABLE_SEARCH_QUERY_GENERATION -app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = ENABLE_RETRIEVAL_QUERY_GENERATION -app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION -app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION -app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION -app.state.config.ENABLE_FOLLOW_UP_GENERATION = ENABLE_FOLLOW_UP_GENERATION -app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE -app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE -app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE -app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = FOLLOW_UP_GENERATION_PROMPT_TEMPLATE -app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE -app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = QUERY_GENERATION_PROMPT_TEMPLATE -app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE -app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH -app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE -app.state.config.ENABLE_VOICE_MODE_PROMPT = ENABLE_VOICE_MODE_PROMPT ######################################## @@ -1506,7 +835,7 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v models.append(model) - model_order_list = request.app.state.config.MODEL_ORDER_LIST + model_order_list = await Config.get('models.order_list') if model_order_list: model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)} # Sort models by order list priority, with fallback for those not in the list @@ -1547,13 +876,16 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend # --- Ollama provider --- ollama_models = getattr(request.app.state, 'OLLAMA_MODELS', None) or {} if model_id in ollama_models: + ollama_config = await Config.get_many('ollama.base_urls', 'ollama.api_configs') + ollama_base_urls = ollama_config.get('ollama.base_urls') or [] + ollama_api_configs = ollama_config.get('ollama.api_configs') or {} url_indices = ollama_models[model_id].get('urls', []) errors = [] for idx in url_indices: - url = request.app.state.config.OLLAMA_BASE_URLS[idx] - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + url = ollama_base_urls[idx] + api_config = ollama_api_configs.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + ollama_api_configs.get(url, {}), ) key = api_config.get('key', None) @@ -1592,14 +924,16 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend # --- OpenAI-compatible providers --- openai_models = getattr(request.app.state, 'OPENAI_MODELS', None) or {} if model_id in openai_models: + openai_config = await Config.get_many('openai.api_configs', 'openai.api_base_urls', 'openai.api_keys') + openai_api_configs = openai_config.get('openai.api_configs') or {} + openai_base_urls = openai_config.get('openai.api_base_urls') or [] + openai_api_keys = openai_config.get('openai.api_keys') or [] model_info = openai_models[model_id] idx = model_info.get('urlIdx') - api_config = request.app.state.config.OPENAI_API_CONFIGS.get(str(idx), {}) + api_config = openai_api_configs.get(str(idx), {}) provider = api_config.get('provider', '') - base_url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = ( - request.app.state.config.OPENAI_API_KEYS[idx] if idx < len(request.app.state.config.OPENAI_API_KEYS) else '' - ) + base_url = openai_base_urls[idx] + key = openai_api_keys[idx] if idx < len(openai_api_keys) else '' if provider == 'llama.cpp': root_url = base_url.rstrip('/').removesuffix('/v1') @@ -1700,7 +1034,7 @@ async def chat_completion( request.state.model = model # Model params: global defaults as base, per-model overrides win - default_model_params = getattr(request.app.state.config, 'DEFAULT_MODEL_PARAMS', None) or {} + default_model_params = await Config.get('models.default_params', {}) or {} model_info_params = { **default_model_params, **(model_info.params.model_dump() if model_info and model_info.params else {}), @@ -1711,7 +1045,7 @@ async def chat_completion( base_model_id = model_info.base_model_id if base_model_id not in request.app.state.MODELS: if ENABLE_CUSTOM_MODEL_FALLBACK: - default_models = (request.app.state.config.DEFAULT_MODELS or '').split(',') + default_models = ((await Config.get('ui.default_models')) or '').split(',') fallback_model_id = default_models[0].strip() if default_models[0] else None @@ -1771,7 +1105,7 @@ async def chat_completion( and not await has_permission( user.id, 'features.direct_tool_servers', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), ) ): tool_servers = None @@ -2403,7 +1737,52 @@ async def get_app_config(request: Request): if user is None: onboarding = not await Users.has_users() - user_count = await Users.get_num_users() if app.state.LICENSE_METADATA else None + license_metadata = getattr(app.state, 'LICENSE_METADATA', None) + user_count = await Users.get_num_users() if license_metadata else None + config = await Config.get_many( + 'oauth.auto_redirect', + 'ldap.enable', + 'ui.enable_signup', + 'ui.enable_login_form', + 'auth.enable_api_keys', + 'ui.enable_password_change_form', + 'direct.enable', + 'folders.enable', + 'folders.max_file_count', + 'channels.enable', + 'calendar.enable', + 'automations.enable', + 'notes.enable', + 'rag.web.search.enable', + 'code_execution.enable', + 'code_interpreter.enable', + 'image_generation.enable', + 'task.autocomplete.enable', + 'ui.enable_community_sharing', + 'ui.enable_message_rating', + 'ui.enable_user_webhooks', + 'users.enable_status', + 'google_drive.enable', + 'onedrive.enable', + 'memories.enable', + 'ui.default_models', + 'ui.default_pinned_models', + 'ui.prompt_suggestions', + 'code_execution.engine', + 'code_interpreter.engine', + 'audio.tts.engine', + 'audio.tts.voice', + 'audio.tts.split_on', + 'audio.stt.engine', + 'rag.file.max_size', + 'rag.file.max_count', + 'file.image_compression_width', + 'file.image_compression_height', + 'user.permissions', + 'ui.pending_user_overlay_title', + 'ui.pending_user_overlay_content', + 'ui.watermark', + ) return { **({'onboarding': True} if onboarding else {}), @@ -2413,53 +1792,53 @@ async def get_app_config(request: Request): 'default_locale': str(DEFAULT_LOCALE), 'oauth': { 'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()}, - 'auto_redirect': app.state.config.OAUTH_AUTO_REDIRECT, + 'auto_redirect': config.get('oauth.auto_redirect'), }, 'features': { # --- Public: required by login/signup page pre-auth --- 'auth': WEBUI_AUTH, - 'auth_trusted_header': bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), + 'auth_trusted_header': bool(WEBUI_AUTH_TRUSTED_EMAIL_HEADER), 'enable_signup_password_confirmation': ENABLE_SIGNUP_PASSWORD_CONFIRMATION, - 'enable_ldap': app.state.config.ENABLE_LDAP, - 'enable_signup': app.state.config.ENABLE_SIGNUP, - 'enable_login_form': app.state.config.ENABLE_LOGIN_FORM, + 'enable_ldap': config.get('ldap.enable'), + 'enable_signup': config.get('ui.enable_signup'), + 'enable_login_form': config.get('ui.enable_login_form'), 'enable_websocket': ENABLE_WEBSOCKET_SUPPORT, # --- Authenticated: only consumed by logged-in frontend --- **( { - 'enable_api_keys': app.state.config.ENABLE_API_KEYS, - 'enable_password_change_form': app.state.config.ENABLE_PASSWORD_CHANGE_FORM, + 'enable_api_keys': config.get('auth.enable_api_keys'), + 'enable_password_change_form': config.get('ui.enable_password_change_form'), 'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK, 'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT, 'enable_easter_eggs': ENABLE_EASTER_EGGS, - 'enable_direct_connections': app.state.config.ENABLE_DIRECT_CONNECTIONS, - 'enable_folders': app.state.config.ENABLE_FOLDERS, - 'folder_max_file_count': app.state.config.FOLDER_MAX_FILE_COUNT, - 'enable_channels': app.state.config.ENABLE_CHANNELS, - 'enable_calendar': app.state.config.ENABLE_CALENDAR, - 'enable_automations': app.state.config.ENABLE_AUTOMATIONS, - 'enable_notes': app.state.config.ENABLE_NOTES, - 'enable_web_search': app.state.config.ENABLE_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_user_status': app.state.config.ENABLE_USER_STATUS, + 'enable_direct_connections': config.get('direct.enable'), + 'enable_folders': config.get('folders.enable'), + 'folder_max_file_count': config.get('folders.max_file_count'), + 'enable_channels': config.get('channels.enable'), + 'enable_calendar': config.get('calendar.enable'), + 'enable_automations': config.get('automations.enable'), + 'enable_notes': config.get('notes.enable'), + 'enable_web_search': config.get('rag.web.search.enable'), + 'enable_code_execution': config.get('code_execution.enable'), + 'enable_code_interpreter': config.get('code_interpreter.enable'), + 'enable_image_generation': config.get('image_generation.enable'), + 'enable_autocomplete_generation': config.get('task.autocomplete.enable'), + 'enable_community_sharing': config.get('ui.enable_community_sharing'), + 'enable_message_rating': config.get('ui.enable_message_rating'), + 'enable_user_webhooks': config.get('ui.enable_user_webhooks'), + 'enable_user_status': config.get('users.enable_status'), 'enable_admin_export': ENABLE_ADMIN_EXPORT, 'enable_admin_chat_access': ENABLE_ADMIN_CHAT_ACCESS, 'enable_admin_analytics': ENABLE_ADMIN_ANALYTICS, - 'enable_google_drive_integration': app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - 'enable_onedrive_integration': app.state.config.ENABLE_ONEDRIVE_INTEGRATION, - 'enable_memories': app.state.config.ENABLE_MEMORIES, + 'enable_google_drive_integration': config.get('google_drive.enable'), + 'enable_onedrive_integration': config.get('onedrive.enable'), + 'enable_memories': config.get('memories.enable'), **( { 'enable_onedrive_personal': ENABLE_ONEDRIVE_PERSONAL, 'enable_onedrive_business': ENABLE_ONEDRIVE_BUSINESS, } - if app.state.config.ENABLE_ONEDRIVE_INTEGRATION + if config.get('onedrive.enable') else {} ), } @@ -2469,55 +1848,55 @@ async def get_app_config(request: Request): }, **( { - 'default_models': app.state.config.DEFAULT_MODELS, - 'default_pinned_models': app.state.config.DEFAULT_PINNED_MODELS, - 'default_prompt_suggestions': app.state.config.DEFAULT_PROMPT_SUGGESTIONS, + 'default_models': config.get('ui.default_models'), + 'default_pinned_models': config.get('ui.default_pinned_models'), + 'default_prompt_suggestions': config.get('ui.prompt_suggestions'), **({'user_count': user_count} if user_count is not None else {}), 'code': { - 'engine': app.state.config.CODE_EXECUTION_ENGINE, - 'interpreter_engine': app.state.config.CODE_INTERPRETER_ENGINE, + 'engine': config.get('code_execution.engine'), + 'interpreter_engine': config.get('code_interpreter.engine'), }, 'audio': { 'tts': { - 'engine': app.state.config.TTS_ENGINE, - 'voice': app.state.config.TTS_VOICE, - 'split_on': app.state.config.TTS_SPLIT_ON, + 'engine': config.get('audio.tts.engine'), + 'voice': config.get('audio.tts.voice'), + 'split_on': config.get('audio.tts.split_on'), }, 'stt': { - 'engine': app.state.config.STT_ENGINE, + 'engine': config.get('audio.stt.engine'), }, }, 'file': { - 'max_size': app.state.config.FILE_MAX_SIZE, - 'max_count': app.state.config.FILE_MAX_COUNT, + 'max_size': config.get('rag.file.max_size'), + 'max_count': config.get('rag.file.max_count'), 'image_compression': { - 'width': app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - 'height': app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'width': config.get('file.image_compression_width'), + 'height': config.get('file.image_compression_height'), }, }, - 'permissions': {**app.state.config.USER_PERMISSIONS}, + 'permissions': {**(config.get('user.permissions') or {})}, 'google_drive': { - 'client_id': GOOGLE_DRIVE_CLIENT_ID.value, - 'api_key': GOOGLE_DRIVE_API_KEY.value, + 'client_id': GOOGLE_DRIVE_CLIENT_ID, + 'api_key': GOOGLE_DRIVE_API_KEY, }, 'onedrive': { 'client_id_personal': ONEDRIVE_CLIENT_ID_PERSONAL, 'client_id_business': ONEDRIVE_CLIENT_ID_BUSINESS, - 'sharepoint_url': ONEDRIVE_SHAREPOINT_URL.value, - 'sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID.value, + 'sharepoint_url': ONEDRIVE_SHAREPOINT_URL, + 'sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID, }, 'ui': { - 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, - 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, - 'response_watermark': app.state.config.RESPONSE_WATERMARK, + 'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'), + 'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'), + 'response_watermark': config.get('ui.watermark'), 'iframe_csp': IFRAME_CSP, }, - 'license_metadata': app.state.LICENSE_METADATA, + 'license_metadata': license_metadata, **( { - 'active_entries': app.state.USER_COUNT, + 'active_entries': user_count, } - if user.role == 'admin' + if user.role == 'admin' and user_count is not None else {} ), } @@ -2526,8 +1905,8 @@ async def get_app_config(request: Request): **( { 'ui': { - 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, - 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'), + 'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'), } } if user and user.role == 'pending' @@ -2536,11 +1915,11 @@ async def get_app_config(request: Request): **( { 'metadata': { - 'login_footer': app.state.LICENSE_METADATA.get('login_footer', ''), - 'auth_logo_position': app.state.LICENSE_METADATA.get('auth_logo_position', ''), + 'login_footer': license_metadata.get('login_footer', ''), + 'auth_logo_position': license_metadata.get('auth_logo_position', ''), } } - if app.state.LICENSE_METADATA + if license_metadata else {} ), } @@ -2555,15 +1934,15 @@ class UrlForm(BaseModel): @app.get('/api/webhook') async def get_webhook_url(user=Depends(get_admin_user)): return { - 'url': app.state.config.WEBHOOK_URL, + 'url': await Config.get('webhook_url'), } @app.post('/api/webhook') async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)): - app.state.config.WEBHOOK_URL = form_data.url - app.state.WEBHOOK_URL = app.state.config.WEBHOOK_URL - return {'url': app.state.config.WEBHOOK_URL} + await Config.upsert({'webhook_url': form_data.url}) + app.state.WEBHOOK_URL = form_data.url + return {'url': form_data.url} @app.get('/api/version') @@ -2629,24 +2008,6 @@ async def get_current_usage(user=Depends(get_verified_user)): # --- OAuth Login & Callback --- -# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1 -if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: - for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS: - if tool_server_connection.get('type', 'openapi') == 'mcp': - server_id = tool_server_connection.get('info', {}).get('id') - auth_type = tool_server_connection.get('auth_type', 'none') - - if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): - try: - oauth_client_info = resolve_oauth_client_info(tool_server_connection) - app.state.oauth_client_manager.add_client( - f'mcp:{server_id}', - OAuthClientInformationFull(**oauth_client_info), - ) - except Exception as e: - log.error(f'Error adding OAuth client for MCP tool server {server_id}: {e}') - pass - try: if ENABLE_STAR_SESSIONS_MIDDLEWARE: redis_session_store = RedisStore( @@ -2681,7 +2042,8 @@ async def register_client(request, client_id: str) -> bool: connection = None connection_idx = None - for idx, conn in enumerate(request.app.state.config.TOOL_SERVER_CONNECTIONS or []): + tool_server_connections = await Config.get('tool_server.connections', []) or [] + for idx, conn in enumerate(tool_server_connections): if conn.get('type', 'openapi') == server_type: info = conn.get('info', {}) if info.get('id') == server_id: @@ -2731,7 +2093,7 @@ async def register_client(request, client_id: str) -> bool: return False try: - connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + connections = await Config.get('tool_server.connections', []) or [] connections[connection_idx] = { **connection, 'info': { @@ -2739,9 +2101,7 @@ async def register_client(request, client_id: str) -> bool: 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), }, } - # Re-assign the full list to trigger AppConfig.__setattr__ → ConfigVar.save() - # (in-place list mutation via list[idx] = ... does not trigger __setattr__) - request.app.state.config.TOOL_SERVER_CONNECTIONS = connections + await Config.upsert({'tool_server.connections': connections}) except Exception as e: log.error(f'Failed to persist updated OAuth client info for tool server {client_id}: {e}') return False @@ -2760,8 +2120,8 @@ async def oauth_client_authorize( user=Depends(get_verified_user), ): # ensure_valid_client_registration - client = oauth_client_manager.get_client(client_id) - client_info = oauth_client_manager.get_client_info(client_id) + client = await oauth_client_manager.get_client(client_id) + client_info = await oauth_client_manager.get_client_info(client_id) if client is None or client_info is None: raise HTTPException(status.HTTP_404_NOT_FOUND) @@ -2778,8 +2138,8 @@ async def oauth_client_authorize( detail='Failed to re-register OAuth client', ) - client = oauth_client_manager.get_client(client_id) - client_info = oauth_client_manager.get_client_info(client_id) + client = await oauth_client_manager.get_client(client_id) + client_info = await oauth_client_manager.get_client_info(client_id) if client is None or client_info is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2852,10 +2212,11 @@ async def oauth_backchannel_logout( @app.get('/manifest.json') async def get_manifest_json(): - if app.state.EXTERNAL_PWA_MANIFEST_URL: + external_pwa_manifest_url = getattr(app.state, 'EXTERNAL_PWA_MANIFEST_URL', None) + if external_pwa_manifest_url: session = await get_session() async with session.get( - app.state.EXTERNAL_PWA_MANIFEST_URL, + external_pwa_manifest_url, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() @@ -2892,14 +2253,15 @@ async def get_manifest_json(): @app.get('/opensearch.xml') async def get_opensearch_xml(): + webui_url = await Config.get('webui.url') xml_content = rf""" {app.state.WEBUI_NAME} Search {app.state.WEBUI_NAME} UTF-8 - {app.state.config.WEBUI_URL}/static/favicon.png - - {app.state.config.WEBUI_URL} + {webui_url}/static/favicon.png + + {webui_url} """ return Response(content=xml_content, media_type='application/xml') diff --git a/backend/open_webui/migrations/versions/3ff2c63645b8_reshape_config_to_per_key_rows.py b/backend/open_webui/migrations/versions/3ff2c63645b8_reshape_config_to_per_key_rows.py new file mode 100644 index 00000000000..904bef45858 --- /dev/null +++ b/backend/open_webui/migrations/versions/3ff2c63645b8_reshape_config_to_per_key_rows.py @@ -0,0 +1,580 @@ +"""reshape config to per key rows + +Revision ID: 3ff2c63645b8 +Revises: 461111b60977 +Create Date: 2026-06-17 00:50:51.477073 + +""" + +import json +import time +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '3ff2c63645b8' +down_revision: Union[str, None] = '461111b60977' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Maps every dot-notation blob path to its legacy env/config key name. +# Built from the legacy persistent config declarations in config.py. +BLOB_PATH_TO_KEY = { + "audio.stt.allowed_extensions": "AUDIO_STT_ALLOWED_EXTENSIONS", + "audio.stt.azure.api_key": "AUDIO_STT_AZURE_API_KEY", + "audio.stt.azure.base_url": "AUDIO_STT_AZURE_BASE_URL", + "audio.stt.azure.locales": "AUDIO_STT_AZURE_LOCALES", + "audio.stt.azure.max_speakers": "AUDIO_STT_AZURE_MAX_SPEAKERS", + "audio.stt.azure.region": "AUDIO_STT_AZURE_REGION", + "audio.stt.deepgram.api_key": "DEEPGRAM_API_KEY", + "audio.stt.engine": "AUDIO_STT_ENGINE", + "audio.stt.mistral.api_base_url": "AUDIO_STT_MISTRAL_API_BASE_URL", + "audio.stt.mistral.api_key": "AUDIO_STT_MISTRAL_API_KEY", + "audio.stt.mistral.use_chat_completions": "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", + "audio.stt.model": "AUDIO_STT_MODEL", + "audio.stt.openai.api_base_url": "AUDIO_STT_OPENAI_API_BASE_URL", + "audio.stt.openai.api_key": "AUDIO_STT_OPENAI_API_KEY", + "audio.stt.supported_content_types": "AUDIO_STT_SUPPORTED_CONTENT_TYPES", + "audio.stt.whisper_model": "WHISPER_MODEL", + "audio.tts.api_key": "AUDIO_TTS_API_KEY", + "audio.tts.azure.speech_base_url": "AUDIO_TTS_AZURE_SPEECH_BASE_URL", + "audio.tts.azure.speech_output_format": "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT", + "audio.tts.azure.speech_region": "AUDIO_TTS_AZURE_SPEECH_REGION", + "audio.tts.engine": "AUDIO_TTS_ENGINE", + "audio.tts.mistral.api_base_url": "AUDIO_TTS_MISTRAL_API_BASE_URL", + "audio.tts.mistral.api_key": "AUDIO_TTS_MISTRAL_API_KEY", + "audio.tts.model": "AUDIO_TTS_MODEL", + "audio.tts.openai.api_base_url": "AUDIO_TTS_OPENAI_API_BASE_URL", + "audio.tts.openai.api_key": "AUDIO_TTS_OPENAI_API_KEY", + "audio.tts.openai.params": "AUDIO_TTS_OPENAI_PARAMS", + "audio.tts.split_on": "AUDIO_TTS_SPLIT_ON", + "audio.tts.voice": "AUDIO_TTS_VOICE", + "auth.admin.email": "ADMIN_EMAIL", + "auth.admin.show": "SHOW_ADMIN_DETAILS", + "auth.api_key.allowed_endpoints": "API_KEYS_ALLOWED_ENDPOINTS", + "auth.api_key.endpoint_restrictions": "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS", + "auth.enable_api_keys": "ENABLE_API_KEYS", + "auth.jwt_expiry": "JWT_EXPIRES_IN", + "automations.enable": "ENABLE_AUTOMATIONS", + "automations.max_count": "AUTOMATION_MAX_COUNT", + "automations.min_interval": "AUTOMATION_MIN_INTERVAL", + "calendar.enable": "ENABLE_CALENDAR", + "channels.enable": "ENABLE_CHANNELS", + "code_execution.enable": "ENABLE_CODE_EXECUTION", + "code_execution.engine": "CODE_EXECUTION_ENGINE", + "code_execution.jupyter.auth": "CODE_EXECUTION_JUPYTER_AUTH", + "code_execution.jupyter.auth_password": "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", + "code_execution.jupyter.auth_token": "CODE_EXECUTION_JUPYTER_AUTH_TOKEN", + "code_execution.jupyter.timeout": "CODE_EXECUTION_JUPYTER_TIMEOUT", + "code_execution.jupyter.url": "CODE_EXECUTION_JUPYTER_URL", + "code_interpreter.enable": "ENABLE_CODE_INTERPRETER", + "code_interpreter.engine": "CODE_INTERPRETER_ENGINE", + "code_interpreter.jupyter.auth": "CODE_INTERPRETER_JUPYTER_AUTH", + "code_interpreter.jupyter.auth_password": "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", + "code_interpreter.jupyter.auth_token": "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", + "code_interpreter.jupyter.timeout": "CODE_INTERPRETER_JUPYTER_TIMEOUT", + "code_interpreter.jupyter.url": "CODE_INTERPRETER_JUPYTER_URL", + "code_interpreter.prompt_template": "CODE_INTERPRETER_PROMPT_TEMPLATE", + "direct.enable": "ENABLE_DIRECT_CONNECTIONS", + "evaluation.arena.enable": "ENABLE_EVALUATION_ARENA_MODELS", + "evaluation.arena.models": "EVALUATION_ARENA_MODELS", + "file.image_compression_height": "FILE_IMAGE_COMPRESSION_HEIGHT", + "file.image_compression_width": "FILE_IMAGE_COMPRESSION_WIDTH", + "folders.enable": "ENABLE_FOLDERS", + "folders.max_file_count": "FOLDER_MAX_FILE_COUNT", + "google_drive.api_key": "GOOGLE_DRIVE_API_KEY", + "google_drive.client_id": "GOOGLE_DRIVE_CLIENT_ID", + "google_drive.enable": "ENABLE_GOOGLE_DRIVE_INTEGRATION", + "image_generation.automatic1111.api_auth": "AUTOMATIC1111_API_AUTH", + "image_generation.automatic1111.api_params": "AUTOMATIC1111_PARAMS", + "image_generation.automatic1111.base_url": "AUTOMATIC1111_BASE_URL", + "image_generation.comfyui.api_key": "COMFYUI_API_KEY", + "image_generation.comfyui.base_url": "COMFYUI_BASE_URL", + "image_generation.comfyui.nodes": "COMFYUI_WORKFLOW_NODES", + "image_generation.comfyui.workflow": "COMFYUI_WORKFLOW", + "image_generation.enable": "ENABLE_IMAGE_GENERATION", + "image_generation.engine": "IMAGE_GENERATION_ENGINE", + "image_generation.gemini.api_base_url": "IMAGES_GEMINI_API_BASE_URL", + "image_generation.gemini.api_key": "IMAGES_GEMINI_API_KEY", + "image_generation.gemini.endpoint_method": "IMAGES_GEMINI_ENDPOINT_METHOD", + "image_generation.model": "IMAGE_GENERATION_MODEL", + "image_generation.openai.api_base_url": "IMAGES_OPENAI_API_BASE_URL", + "image_generation.openai.api_key": "IMAGES_OPENAI_API_KEY", + "image_generation.openai.api_version": "IMAGES_OPENAI_API_VERSION", + "image_generation.openai.params": "IMAGES_OPENAI_API_PARAMS", + "image_generation.prompt.enable": "ENABLE_IMAGE_PROMPT_GENERATION", + "image_generation.size": "IMAGE_SIZE", + "image_generation.steps": "IMAGE_STEPS", + "images.edit.comfyui.api_key": "IMAGES_EDIT_COMFYUI_API_KEY", + "images.edit.comfyui.base_url": "IMAGES_EDIT_COMFYUI_BASE_URL", + "images.edit.comfyui.nodes": "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", + "images.edit.comfyui.workflow": "IMAGES_EDIT_COMFYUI_WORKFLOW", + "images.edit.enable": "ENABLE_IMAGE_EDIT", + "images.edit.engine": "IMAGE_EDIT_ENGINE", + "images.edit.gemini.api_base_url": "IMAGES_EDIT_GEMINI_API_BASE_URL", + "images.edit.gemini.api_key": "IMAGES_EDIT_GEMINI_API_KEY", + "images.edit.model": "IMAGE_EDIT_MODEL", + "images.edit.openai.api_base_url": "IMAGES_EDIT_OPENAI_API_BASE_URL", + "images.edit.openai.api_key": "IMAGES_EDIT_OPENAI_API_KEY", + "images.edit.openai.api_version": "IMAGES_EDIT_OPENAI_API_VERSION", + "images.edit.size": "IMAGE_EDIT_SIZE", + "ldap.enable": "ENABLE_LDAP", + "ldap.group.enable_creation": "ENABLE_LDAP_GROUP_CREATION", + "ldap.group.enable_management": "ENABLE_LDAP_GROUP_MANAGEMENT", + "ldap.server.app_dn": "LDAP_APP_DN", + "ldap.server.app_password": "LDAP_APP_PASSWORD", + "ldap.server.attribute_for_groups": "LDAP_ATTRIBUTE_FOR_GROUPS", + "ldap.server.attribute_for_mail": "LDAP_ATTRIBUTE_FOR_MAIL", + "ldap.server.attribute_for_username": "LDAP_ATTRIBUTE_FOR_USERNAME", + "ldap.server.ca_cert_file": "LDAP_CA_CERT_FILE", + "ldap.server.ciphers": "LDAP_CIPHERS", + "ldap.server.host": "LDAP_SERVER_HOST", + "ldap.server.label": "LDAP_SERVER_LABEL", + "ldap.server.port": "LDAP_SERVER_PORT", + "ldap.server.search_filter": "LDAP_SEARCH_FILTER", + "ldap.server.use_tls": "LDAP_USE_TLS", + "ldap.server.users_dn": "LDAP_SEARCH_BASE", + "ldap.server.validate_cert": "LDAP_VALIDATE_CERT", + "memories.enable": "ENABLE_MEMORIES", + "models.base_models_cache": "ENABLE_BASE_MODELS_CACHE", + "models.default_metadata": "DEFAULT_MODEL_METADATA", + "models.default_params": "DEFAULT_MODEL_PARAMS", + "notes.enable": "ENABLE_NOTES", + # OAuth — direct paths + "oauth.admin_roles": "OAUTH_ADMIN_ROLES", + "oauth.allowed_domains": "OAUTH_ALLOWED_DOMAINS", + "oauth.allowed_roles": "OAUTH_ALLOWED_ROLES", + "oauth.audience": "OAUTH_AUDIENCE", + "oauth.auto_redirect": "OAUTH_AUTO_REDIRECT", + "oauth.blocked_groups": "OAUTH_BLOCKED_GROUPS", + "oauth.client.timeout": "OAUTH_CLIENT_TIMEOUT", + "oauth.enable_group_creation": "ENABLE_OAUTH_GROUP_CREATION", + "oauth.enable_group_mapping": "ENABLE_OAUTH_GROUP_MANAGEMENT", + "oauth.enable_role_mapping": "ENABLE_OAUTH_ROLE_MANAGEMENT", + "oauth.enable_signup": "ENABLE_OAUTH_SIGNUP", + "oauth.group_default_share": "OAUTH_GROUP_DEFAULT_SHARE", + "oauth.merge_accounts_by_email": "OAUTH_MERGE_ACCOUNTS_BY_EMAIL", + "oauth.refresh_token_include_scope": "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", + "oauth.roles_claim": "OAUTH_ROLES_CLAIM", + "oauth.update_email_on_login": "OAUTH_UPDATE_EMAIL_ON_LOGIN", + "oauth.update_name_on_login": "OAUTH_UPDATE_NAME_ON_LOGIN", + "oauth.update_picture_on_login": "OAUTH_UPDATE_PICTURE_ON_LOGIN", + # OAuth — generic provider paths + "oauth.client_id": "OAUTH_CLIENT_ID", + "oauth.client_secret": "OAUTH_CLIENT_SECRET", + "oauth.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD", + "oauth.email_claim": "OAUTH_EMAIL_CLAIM", + "oauth.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT", + "oauth.group_claim": "OAUTH_GROUP_CLAIM", + "oauth.picture_claim": "OAUTH_PICTURE_CLAIM", + "oauth.provider_name": "OAUTH_PROVIDER_NAME", + "oauth.provider_url": "OPENID_PROVIDER_URL", + "oauth.redirect_uri": "OPENID_REDIRECT_URI", + "oauth.scopes": "OAUTH_SCOPES", + "oauth.sub_claim": "OAUTH_SUB_CLAIM", + "oauth.timeout": "OAUTH_TIMEOUT", + "oauth.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", + "oauth.username_claim": "OAUTH_USERNAME_CLAIM", + # OAuth — OIDC nested paths (flattened) + "oauth.oidc.avatar_claim": "OAUTH_PICTURE_CLAIM", + "oauth.oidc.client_id": "OAUTH_CLIENT_ID", + "oauth.oidc.client_secret": "OAUTH_CLIENT_SECRET", + "oauth.oidc.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD", + "oauth.oidc.email_claim": "OAUTH_EMAIL_CLAIM", + "oauth.oidc.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT", + "oauth.oidc.group_claim": "OAUTH_GROUP_CLAIM", # renamed from OAUTH_GROUPS_CLAIM + "oauth.oidc.oauth_timeout": "OAUTH_TIMEOUT", + "oauth.oidc.provider_name": "OAUTH_PROVIDER_NAME", + "oauth.oidc.provider_url": "OPENID_PROVIDER_URL", + "oauth.oidc.redirect_uri": "OPENID_REDIRECT_URI", + "oauth.oidc.scopes": "OAUTH_SCOPES", + "oauth.oidc.sub_claim": "OAUTH_SUB_CLAIM", + "oauth.oidc.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", + "oauth.oidc.username_claim": "OAUTH_USERNAME_CLAIM", + # OAuth — provider-specific + "oauth.feishu.client_id": "FEISHU_CLIENT_ID", + "oauth.feishu.client_secret": "FEISHU_CLIENT_SECRET", + "oauth.feishu.redirect_uri": "FEISHU_REDIRECT_URI", + "oauth.feishu.scope": "FEISHU_OAUTH_SCOPE", + "oauth.github.client_id": "GITHUB_CLIENT_ID", + "oauth.github.client_secret": "GITHUB_CLIENT_SECRET", + "oauth.github.redirect_uri": "GITHUB_CLIENT_REDIRECT_URI", + "oauth.github.scope": "GITHUB_CLIENT_SCOPE", + "oauth.google.client_id": "GOOGLE_CLIENT_ID", + "oauth.google.client_secret": "GOOGLE_CLIENT_SECRET", + "oauth.google.redirect_uri": "GOOGLE_REDIRECT_URI", + "oauth.google.scope": "GOOGLE_OAUTH_SCOPE", + "oauth.microsoft.client_id": "MICROSOFT_CLIENT_ID", + "oauth.microsoft.client_secret": "MICROSOFT_CLIENT_SECRET", + "oauth.microsoft.login_base_url": "MICROSOFT_CLIENT_LOGIN_BASE_URL", + "oauth.microsoft.picture_url": "MICROSOFT_CLIENT_PICTURE_URL", + "oauth.microsoft.redirect_uri": "MICROSOFT_REDIRECT_URI", + "oauth.microsoft.scope": "MICROSOFT_OAUTH_SCOPE", + "oauth.microsoft.tenant_id": "MICROSOFT_CLIENT_TENANT_ID", + # Ollama / OpenAI + "ollama.api_configs": "OLLAMA_API_CONFIGS", + "ollama.base_urls": "OLLAMA_BASE_URLS", + "ollama.enable": "ENABLE_OLLAMA_API", + "onedrive.enable": "ENABLE_ONEDRIVE_INTEGRATION", + "onedrive.sharepoint_tenant_id": "ONEDRIVE_SHAREPOINT_TENANT_ID", + "onedrive.sharepoint_url": "ONEDRIVE_SHAREPOINT_URL", + "openai.api_base_urls": "OPENAI_API_BASE_URLS", + "openai.api_configs": "OPENAI_API_CONFIGS", + "openai.api_keys": "OPENAI_API_KEYS", + "openai.enable": "ENABLE_OPENAI_API", + # RAG + "rag.content_extraction_engine": "CONTENT_EXTRACTION_ENGINE", + "rag.datalab_marker_use_llm": "DATALAB_MARKER_USE_LLM", + "rag.mistral_ocr_api_base_url": "MISTRAL_OCR_API_BASE_URL", + "rag.azure_openai.api_key": "RAG_AZURE_OPENAI_API_KEY", + "rag.azure_openai.api_version": "RAG_AZURE_OPENAI_API_VERSION", + "rag.azure_openai.base_url": "RAG_AZURE_OPENAI_BASE_URL", + "rag.bypass_embedding_and_retrieval": "BYPASS_EMBEDDING_AND_RETRIEVAL", + "rag.chunk_min_size_target": "CHUNK_MIN_SIZE_TARGET", + "rag.chunk_overlap": "CHUNK_OVERLAP", + "rag.chunk_size": "CHUNK_SIZE", + "rag.datalab_marker_additional_config": "DATALAB_MARKER_ADDITIONAL_CONFIG", + "rag.datalab_marker_api_base_url": "DATALAB_MARKER_API_BASE_URL", + "rag.datalab_marker_api_key": "DATALAB_MARKER_API_KEY", + "rag.datalab_marker_disable_image_extraction": "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", + "rag.datalab_marker_force_ocr": "DATALAB_MARKER_FORCE_OCR", + "rag.datalab_marker_format_lines": "DATALAB_MARKER_FORMAT_LINES", + "rag.datalab_marker_output_format": "DATALAB_MARKER_OUTPUT_FORMAT", + "rag.datalab_marker_paginate": "DATALAB_MARKER_PAGINATE", + "rag.datalab_marker_skip_cache": "DATALAB_MARKER_SKIP_CACHE", + "rag.datalab_marker_strip_existing_ocr": "DATALAB_MARKER_STRIP_EXISTING_OCR", + "rag.docling_api_key": "DOCLING_API_KEY", + "rag.docling_params": "DOCLING_PARAMS", + "rag.docling_server_url": "DOCLING_SERVER_URL", + "rag.document_intelligence_endpoint": "DOCUMENT_INTELLIGENCE_ENDPOINT", + "rag.document_intelligence_key": "DOCUMENT_INTELLIGENCE_KEY", + "rag.document_intelligence_model": "DOCUMENT_INTELLIGENCE_MODEL", + "rag.embedding_batch_size": "RAG_EMBEDDING_BATCH_SIZE", + "rag.embedding_concurrent_requests": "RAG_EMBEDDING_CONCURRENT_REQUESTS", + "rag.embedding_engine": "RAG_EMBEDDING_ENGINE", + "rag.embedding_model": "RAG_EMBEDDING_MODEL", + "rag.enable_async_embedding": "ENABLE_ASYNC_EMBEDDING", + "rag.enable_hybrid_search": "ENABLE_RAG_HYBRID_SEARCH", + "rag.enable_hybrid_search_enriched_texts": "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS", + "rag.enable_markdown_header_text_splitter": "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", + "rag.external_document_loader_api_key": "EXTERNAL_DOCUMENT_LOADER_API_KEY", + "rag.external_document_loader_url": "EXTERNAL_DOCUMENT_LOADER_URL", + "rag.external_reranker_api_key": "RAG_EXTERNAL_RERANKER_API_KEY", + "rag.external_reranker_timeout": "RAG_EXTERNAL_RERANKER_TIMEOUT", + "rag.external_reranker_url": "RAG_EXTERNAL_RERANKER_URL", + "rag.file.allowed_extensions": "RAG_ALLOWED_FILE_EXTENSIONS", + "rag.file.max_count": "RAG_FILE_MAX_COUNT", + "rag.file.max_size": "RAG_FILE_MAX_SIZE", + "rag.full_context": "RAG_FULL_CONTEXT", + "rag.hybrid_bm25_weight": "RAG_HYBRID_BM25_WEIGHT", + "rag.mineru_api_key": "MINERU_API_KEY", + "rag.mineru_api_mode": "MINERU_API_MODE", + "rag.mineru_api_timeout": "MINERU_API_TIMEOUT", + "rag.mineru_api_url": "MINERU_API_URL", + "rag.mineru_file_extensions": "MINERU_FILE_EXTENSIONS", + "rag.mineru_params": "MINERU_PARAMS", + "rag.mistral_ocr_api_key": "MISTRAL_OCR_API_KEY", + "rag.ollama.key": "RAG_OLLAMA_API_KEY", + "rag.ollama.url": "RAG_OLLAMA_BASE_URL", + "rag.openai_api_base_url": "RAG_OPENAI_API_BASE_URL", + "rag.openai_api_key": "RAG_OPENAI_API_KEY", + "rag.paddleocr_vl_base_url": "PADDLEOCR_VL_BASE_URL", + "rag.paddleocr_vl_token": "PADDLEOCR_VL_TOKEN", + "rag.pdf_extract_images": "PDF_EXTRACT_IMAGES", + "rag.pdf_loader_mode": "PDF_LOADER_MODE", + "rag.relevance_threshold": "RAG_RELEVANCE_THRESHOLD", + "rag.reranking_batch_size": "RAG_RERANKING_BATCH_SIZE", + "rag.reranking_engine": "RAG_RERANKING_ENGINE", + "rag.reranking_model": "RAG_RERANKING_MODEL", + "rag.template": "RAG_TEMPLATE", + "rag.text_splitter": "RAG_TEXT_SPLITTER", + "rag.tika_server_url": "TIKA_SERVER_URL", + "rag.tiktoken_encoding_name": "TIKTOKEN_ENCODING_NAME", + "rag.top_k": "RAG_TOP_K", + "rag.top_k_reranker": "RAG_TOP_K_RERANKER", + # RAG — Web + "rag.web.fetch.max_content_length": "WEB_FETCH_MAX_CONTENT_LENGTH", + "rag.web.loader.concurrent_requests": "WEB_LOADER_CONCURRENT_REQUESTS", + "rag.web.loader.engine": "WEB_LOADER_ENGINE", + "rag.web.loader.external_web_loader_api_key": "EXTERNAL_WEB_LOADER_API_KEY", + "rag.web.loader.external_web_loader_url": "EXTERNAL_WEB_LOADER_URL", + "rag.web.loader.firecrawl_api_key": "FIRECRAWL_API_KEY", + "rag.web.loader.firecrawl_api_url": "FIRECRAWL_API_BASE_URL", + "rag.web.loader.firecrawl_timeout": "FIRECRAWL_TIMEOUT", + "rag.web.loader.playwright_timeout": "PLAYWRIGHT_TIMEOUT", + "rag.web.loader.playwright_ws_url": "PLAYWRIGHT_WS_URL", + "rag.web.loader.ssl_verification": "ENABLE_WEB_LOADER_SSL_VERIFICATION", + "rag.web.loader.timeout": "WEB_LOADER_TIMEOUT", + "rag.web.search.azure_ai_search_api_key": "AZURE_AI_SEARCH_API_KEY", + "rag.web.search.azure_ai_search_endpoint": "AZURE_AI_SEARCH_ENDPOINT", + "rag.web.search.azure_ai_search_index_name": "AZURE_AI_SEARCH_INDEX_NAME", + "rag.web.search.bing_search_v7_endpoint": "BING_SEARCH_V7_ENDPOINT", + "rag.web.search.bing_search_v7_subscription_key": "BING_SEARCH_V7_SUBSCRIPTION_KEY", + "rag.web.search.bocha_search_api_key": "BOCHA_SEARCH_API_KEY", + "rag.web.search.brave_search_api_key": "BRAVE_SEARCH_API_KEY", + "rag.web.search.brave_search_context_tokens": "BRAVE_SEARCH_CONTEXT_TOKENS", + "rag.web.search.bypass_embedding_and_retrieval": "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", + "rag.web.search.bypass_web_loader": "BYPASS_WEB_SEARCH_WEB_LOADER", + "rag.web.search.concurrent_requests": "WEB_SEARCH_CONCURRENT_REQUESTS", + "rag.web.search.ddgs_backend": "DDGS_BACKEND", + "rag.web.search.domain.filter_list": "WEB_SEARCH_DOMAIN_FILTER_LIST", + "rag.web.search.enable": "ENABLE_WEB_SEARCH", + "rag.web.search.engine": "WEB_SEARCH_ENGINE", + "rag.web.search.exa_api_key": "EXA_API_KEY", + "rag.web.search.external_web_search_api_key": "EXTERNAL_WEB_SEARCH_API_KEY", + "rag.web.search.external_web_search_url": "EXTERNAL_WEB_SEARCH_URL", + "rag.web.search.google_pse_api_key": "GOOGLE_PSE_API_KEY", + "rag.web.search.google_pse_engine_id": "GOOGLE_PSE_ENGINE_ID", + "rag.web.search.jina_api_base_url": "JINA_API_BASE_URL", + "rag.web.search.jina_api_key": "JINA_API_KEY", + "rag.web.search.kagi_search_api_key": "KAGI_SEARCH_API_KEY", + "rag.web.search.linkup_api_key": "LINKUP_API_KEY", + "rag.web.search.linkup_search_params": "LINKUP_SEARCH_PARAMS", + "rag.web.search.mojeek_search_api_key": "MOJEEK_SEARCH_API_KEY", + "rag.web.search.ollama_cloud_api_key": "OLLAMA_CLOUD_WEB_SEARCH_API_KEY", + "rag.web.search.perplexity_api_key": "PERPLEXITY_API_KEY", + "rag.web.search.perplexity_model": "PERPLEXITY_MODEL", + "rag.web.search.perplexity_search_api_url": "PERPLEXITY_SEARCH_API_URL", + "rag.web.search.perplexity_search_context_usage": "PERPLEXITY_SEARCH_CONTEXT_USAGE", + "rag.web.search.result_count": "WEB_SEARCH_RESULT_COUNT", + "rag.web.search.searchapi_api_key": "SEARCHAPI_API_KEY", + "rag.web.search.searchapi_engine": "SEARCHAPI_ENGINE", + "rag.web.search.searxng_language": "SEARXNG_LANGUAGE", + "rag.web.search.searxng_query_url": "SEARXNG_QUERY_URL", + "rag.web.search.serpapi_api_key": "SERPAPI_API_KEY", + "rag.web.search.serpapi_engine": "SERPAPI_ENGINE", + "rag.web.search.serper_api_key": "SERPER_API_KEY", + "rag.web.search.serply_api_key": "SERPLY_API_KEY", + "rag.web.search.serpstack_api_key": "SERPSTACK_API_KEY", + "rag.web.search.serpstack_https": "SERPSTACK_HTTPS", + "rag.web.search.sougou_api_sid": "SOUGOU_API_SID", + "rag.web.search.sougou_api_sk": "SOUGOU_API_SK", + "rag.web.search.tavily_api_key": "TAVILY_API_KEY", + "rag.web.search.tavily_extract_depth": "TAVILY_EXTRACT_DEPTH", + "rag.web.search.trust_env": "WEB_SEARCH_TRUST_ENV", + "rag.web.search.yacy_password": "YACY_PASSWORD", + "rag.web.search.yacy_query_url": "YACY_QUERY_URL", + "rag.web.search.yacy_username": "YACY_USERNAME", + "rag.web.search.yandex_web_search_api_key": "YANDEX_WEB_SEARCH_API_KEY", + "rag.web.search.yandex_web_search_config": "YANDEX_WEB_SEARCH_CONFIG", + "rag.web.search.yandex_web_search_url": "YANDEX_WEB_SEARCH_URL", + "rag.web.search.youcom_api_key": "YOUCOM_API_KEY", + "rag.youtube_loader_language": "YOUTUBE_LOADER_LANGUAGE", + "rag.youtube_loader_proxy_url": "YOUTUBE_LOADER_PROXY_URL", + # Tasks + "task.autocomplete.enable": "ENABLE_AUTOCOMPLETE_GENERATION", + "task.autocomplete.input_max_length": "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", + "task.autocomplete.prompt_template": "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE", + "task.follow_up.enable": "ENABLE_FOLLOW_UP_GENERATION", + "task.follow_up.prompt_template": "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE", + "task.image.prompt_template": "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", + "task.model.default": "TASK_MODEL", + "task.model.external": "TASK_MODEL_EXTERNAL", + "task.query.prompt_template": "QUERY_GENERATION_PROMPT_TEMPLATE", + "task.query.retrieval.enable": "ENABLE_RETRIEVAL_QUERY_GENERATION", + "task.query.search.enable": "ENABLE_SEARCH_QUERY_GENERATION", + "task.tags.enable": "ENABLE_TAGS_GENERATION", + "task.tags.prompt_template": "TAGS_GENERATION_PROMPT_TEMPLATE", + "task.title.enable": "ENABLE_TITLE_GENERATION", + "task.title.prompt_template": "TITLE_GENERATION_PROMPT_TEMPLATE", + "task.tools.prompt_template": "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", + "task.voice.prompt.enable": "ENABLE_VOICE_MODE_PROMPT", + "task.voice.prompt_template": "VOICE_MODE_PROMPT_TEMPLATE", + # Misc + "terminal_server.connections": "TERMINAL_SERVER_CONNECTIONS", + "tool_server.connections": "TOOL_SERVER_CONNECTIONS", + "ui.banners": "WEBUI_BANNERS", + "ui.default_group_id": "DEFAULT_GROUP_ID", + "ui.default_locale": "DEFAULT_LOCALE", + "ui.default_models": "DEFAULT_MODELS", + "ui.default_pinned_models": "DEFAULT_PINNED_MODELS", + "ui.default_user_role": "DEFAULT_USER_ROLE", + "ui.enable_community_sharing": "ENABLE_COMMUNITY_SHARING", + "ui.enable_login_form": "ENABLE_LOGIN_FORM", + "ui.enable_message_rating": "ENABLE_MESSAGE_RATING", + "ui.enable_password_change_form": "ENABLE_PASSWORD_CHANGE_FORM", + "ui.enable_signup": "ENABLE_SIGNUP", + "ui.enable_user_webhooks": "ENABLE_USER_WEBHOOKS", + "ui.model_order_list": "MODEL_ORDER_LIST", + "ui.pending_user_overlay_content": "PENDING_USER_OVERLAY_CONTENT", + "ui.pending_user_overlay_title": "PENDING_USER_OVERLAY_TITLE", + "ui.prompt_suggestions": "DEFAULT_PROMPT_SUGGESTIONS", + "ui.watermark": "RESPONSE_WATERMARK", + "user.permissions": "USER_PERMISSIONS", + "users.enable_status": "ENABLE_USER_STATUS", + "webhook_url": "WEBHOOK_URL", + "webui.url": "WEBUI_URL", +} + + +STORAGE_KEY_REWRITES = { + "oauth.refresh_token_include_scope": "oauth.refresh_token.include_scope", + + "rag.openai_api_base_url": "rag.openai.api_base_url", + "rag.openai_api_key": "rag.openai.api_key", + "rag.ollama.url": "rag.ollama.base_url", + "rag.ollama.key": "rag.ollama.api_key", + "oauth.oidc.avatar_claim": "oauth.picture_claim", + "oauth.oidc.client_id": "oauth.client_id", + "oauth.oidc.client_secret": "oauth.client_secret", + "oauth.oidc.code_challenge_method": "oauth.code_challenge_method", + "oauth.oidc.email_claim": "oauth.email_claim", + "oauth.oidc.end_session_endpoint": "oauth.end_session_endpoint", + "oauth.oidc.group_claim": "oauth.group_claim", + "oauth.oidc.oauth_timeout": "oauth.timeout", + "oauth.oidc.provider_name": "oauth.provider_name", + "oauth.oidc.provider_url": "oauth.provider_url", + "oauth.oidc.redirect_uri": "oauth.redirect_uri", + "oauth.oidc.scopes": "oauth.scopes", + "oauth.oidc.sub_claim": "oauth.sub_claim", + "oauth.oidc.token_endpoint_auth_method": "oauth.token_endpoint_auth_method", + "oauth.oidc.username_claim": "oauth.username_claim", +} + + +LEGACY_KEY_TO_STORAGE_KEY = { + legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path) + for blob_path, legacy_key in BLOB_PATH_TO_KEY.items() +} + + +def _walk_blob(data: dict, prefix: str = '') -> dict: + """Recursively walk a nested dict, yielding (dot.path, value) for leaf nodes.""" + result = {} + for key, value in data.items(): + path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}' + if isinstance(value, dict): + result.update(_walk_blob(value, path)) + else: + result[path] = value + return result + + +def upgrade() -> None: + """Reshape config from single-row JSON blob to per-key rows.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + table_names = set(inspector.get_table_names()) + config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set() + has_old_config = {'id', 'data'}.issubset(config_columns) + has_new_config = {'key', 'value'}.issubset(config_columns) + + # Ad-hoc table reference for reading the old schema + old_config = sa.table( + 'config', + sa.column('id', sa.Integer), + sa.column('data', sa.JSON), + ) + + # 1. Read existing blob + blob_data = {} + if has_old_config: + try: + result = conn.execute( + sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1) + ) + row = result.fetchone() + if row and row[0]: + raw = row[0] + blob_data = json.loads(raw) if isinstance(raw, str) else raw + except Exception: + pass # Table might be partially migrated or empty + + # 2. Preserve old blob table for rollback/inspection, then create per-key table. + if has_old_config: + if 'config_old' in table_names: + op.drop_table('config_old') + op.rename_table('config', 'config_old') + + # 3. Create new per-key table + new_config = ( + sa.table( + 'config', + sa.column('key', sa.Text), + sa.column('value', sa.JSON()), + sa.column('updated_at', sa.BigInteger), + ) + if has_new_config + else op.create_table( + 'config', + sa.Column('key', sa.Text(), primary_key=True), + sa.Column('value', sa.JSON(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + ) + ) + + # 4. Flatten blob and insert per-key rows + if blob_data: + flat = _walk_blob(blob_data) + + # Keep stable dot-notation paths as the database keys. + # Known legacy env-style keys are rewritten to their dotted keys; unknown + # keys are still copied so custom/future config is not silently lost. + rows = {} + for blob_path, value in flat.items(): + if blob_path in BLOB_PATH_TO_KEY: + storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path) + elif blob_path in LEGACY_KEY_TO_STORAGE_KEY: + storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path] + else: + storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path) + + if storage_key not in rows: + rows[storage_key] = value + + # Batch insert via SQLAlchemy table reference + if rows: + now = int(time.time()) + op.bulk_insert( + new_config, + [ + {'key': k, 'value': v, 'updated_at': now} + for k, v in rows.items() + ], + ) + + +def downgrade() -> None: + """Restore preserved old single-row config table when available.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + table_names = set(inspector.get_table_names()) + + if 'config_old' in table_names: + if 'config' in table_names: + op.drop_table('config') + op.rename_table('config_old', 'config') + return + + config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set() + has_per_key_config = {'key', 'value'}.issubset(config_columns) + + blob_data = {} + if has_per_key_config: + config = sa.table( + 'config', + sa.column('key', sa.Text), + sa.column('value', sa.JSON), + ) + for key, value in conn.execute(sa.select(config.c.key, config.c.value)): + blob_data[key] = json.loads(value) if isinstance(value, str) else value + op.drop_table('config') + + if 'config' in table_names and not has_per_key_config: + return + + old_config = op.create_table( + 'config', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('data', sa.JSON(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=True), + ) + + if blob_data: + op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}]) diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py new file mode 100644 index 00000000000..0641ee907a5 --- /dev/null +++ b/backend/open_webui/models/config.py @@ -0,0 +1,177 @@ +"""Database-backed configuration with per-key storage. + +Replaces the old single-row JSON blob machinery with a simple per-key model +mirroring cptr's Config. + +Each config key is stored as its own row: key TEXT PK, value JSON. +Reads are direct DB lookups. Writes are explicit awaited upserts that raise on +failure (no more fire-and-forget create_task). +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, ClassVar + +from open_webui.internal.db import Base, get_async_db +from sqlalchemy import JSON, BigInteger, Column, Text, select + +log = logging.getLogger(__name__) + + +# ── Model ──────────────────────────────────────────────────────────────────── + + +class Config(Base): + """Per-key config storage. Each row is one config key.""" + + __tablename__ = 'config' + + key = Column(Text, primary_key=True) + value = Column(JSON, nullable=False) + updated_at = Column(BigInteger, nullable=True) + + DEFAULTS: ClassVar[dict[str, Any]] = {} + PERSISTENT_ENABLED: ClassVar[bool] = True + OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False + + # ── Class methods ──────────────────────────────────────── + + @classmethod + def configure( + cls, + *, + defaults: dict[str, Any] | None = None, + enable_persistent: bool = True, + enable_oauth_persistent: bool = False, + ) -> None: + cls.DEFAULTS = defaults or {} + cls.PERSISTENT_ENABLED = enable_persistent + cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent + + @classmethod + def default_value(cls, key: str, default: Any = None) -> Any: + return cls.DEFAULTS.get(key, default) + + @classmethod + def persistent_enabled_for(cls, key: str) -> bool: + if not cls.PERSISTENT_ENABLED: + return False + if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED: + return False + return True + + @staticmethod + async def get(key: str, default: Any = None) -> Any: + """Get a config value by key. Returns default if not set.""" + if not Config.persistent_enabled_for(key): + return Config.default_value(key, default) + async with get_async_db() as db: + row = await db.get(Config, key) + return row.value if row else Config.default_value(key, default) + + @staticmethod + async def get_many(*keys: str) -> dict: + """Get multiple config values. Returns {key: value} for keys that exist.""" + disabled_values = { + key: Config.default_value(key) + for key in keys + if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS + } + enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)} + if not enabled_keys: + return disabled_values + async with get_async_db() as db: + result = await db.execute(select(Config).where(Config.key.in_(enabled_keys))) + values = {row.key: row.value for row in result.scalars().all()} + return { + key: values.get(key, Config.default_value(key)) + for key in keys + if key in values or key in Config.DEFAULTS or key in disabled_values + } + + @staticmethod + async def get_namespace(namespace: str) -> dict: + """Get all config keys under a dotted namespace.""" + default_values = { + key: value + for key, value in Config.DEFAULTS.items() + if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key) + } + if not Config.PERSISTENT_ENABLED: + return default_values + async with get_async_db() as db: + result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%'))) + values = {row.key: row.value for row in result.scalars().all()} + values.update(default_values) + return values + + @staticmethod + async def get_all() -> dict: + """Get all config as {key: value}.""" + if not Config.PERSISTENT_ENABLED: + return dict(Config.DEFAULTS) + async with get_async_db() as db: + result = await db.execute(select(Config)) + values = {row.key: row.value for row in result.scalars().all()} + if not Config.OAUTH_PERSISTENT_ENABLED: + values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')}) + return values + + @staticmethod + async def upsert(updates: dict) -> None: + """Upsert multiple config key-value pairs. Raises on failure.""" + async with get_async_db() as db: + now = int(time.time()) + for key, value in updates.items(): + existing = await db.get(Config, key) + if existing: + existing.value = value + existing.updated_at = now + else: + db.add(Config(key=key, value=value, updated_at=now)) + await db.commit() + + @staticmethod + async def delete(key: str) -> bool: + """Delete a config key. Returns True if it existed.""" + async with get_async_db() as db: + row = await db.get(Config, key) + if row: + await db.delete(row) + await db.commit() + return True + return False + + @staticmethod + async def clear() -> None: + """Delete all config rows.""" + from sqlalchemy import delete as sa_delete + + async with get_async_db() as db: + await db.execute(sa_delete(Config)) + await db.commit() + + @staticmethod + async def seed_defaults(defaults: dict) -> None: + """Insert keys that don't yet exist in the DB. + + Called at startup to ensure all known config keys have values. + Existing DB values take precedence over defaults. + """ + async with get_async_db() as db: + result = await db.execute(select(Config.key)) + existing_keys = {row[0] for row in result.all()} + + now = int(time.time()) + new_count = 0 + for key, value in defaults.items(): + if key not in existing_keys: + db.add(Config(key=key, value=value, updated_at=now)) + existing_keys.add(key) + new_count += 1 + + if new_count: + await db.commit() + log.info('Seeded %d new config defaults', new_count) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2db9f47c53a..16f6dad18a7 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -39,6 +39,7 @@ from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges from open_webui.models.notes import Notes +from open_webui.models.config import Config from open_webui.models.users import UserModel from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT @@ -63,65 +64,84 @@ def is_youtube_url(url: str) -> bool: return re.match(youtube_regex, url) is not None -def get_loader(request, url: str): +LOADER_CONFIG_KEYS = { + 'youtube_language': 'rag.youtube_loader_language', + 'youtube_proxy_url': 'rag.youtube_loader_proxy_url', + 'web_loader_ssl_verification': 'rag.web.loader.ssl_verification', + 'web_loader_concurrent_requests': 'rag.web.loader.concurrent_requests', + 'web_search_trust_env': 'rag.web.search.trust_env', + 'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine', + 'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key', + 'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url', + 'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config', + 'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache', + 'DATALAB_MARKER_FORCE_OCR': 'rag.datalab_marker_force_ocr', + 'DATALAB_MARKER_PAGINATE': 'rag.datalab_marker_paginate', + 'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr', + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': 'rag.datalab_marker_disable_image_extraction', + 'DATALAB_MARKER_FORMAT_LINES': 'rag.datalab_marker_format_lines', + 'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm', + 'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format', + 'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url', + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', + 'TIKA_SERVER_URL': 'rag.tika_server_url', + 'DOCLING_SERVER_URL': 'rag.docling_server_url', + 'DOCLING_API_KEY': 'rag.docling_api_key', + 'DOCLING_PARAMS': 'rag.docling_params', + 'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images', + 'PDF_LOADER_MODE': 'rag.pdf_loader_mode', + 'DOCUMENT_INTELLIGENCE_ENDPOINT': 'rag.document_intelligence_endpoint', + 'DOCUMENT_INTELLIGENCE_KEY': 'rag.document_intelligence_key', + 'DOCUMENT_INTELLIGENCE_MODEL': 'rag.document_intelligence_model', + 'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url', + 'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key', + 'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url', + 'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token', + 'MINERU_API_MODE': 'rag.mineru_api_mode', + 'MINERU_API_URL': 'rag.mineru_api_url', + 'MINERU_API_KEY': 'rag.mineru_api_key', + 'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout', + 'MINERU_PARAMS': 'rag.mineru_params', + 'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions', +} + + +async def get_loader_config(): + values = await Config.get_many(*LOADER_CONFIG_KEYS.values()) + return {name: values.get(key) for name, key in LOADER_CONFIG_KEYS.items()} + + +def get_loader(request, url: str, config: dict): if is_youtube_url(url): return YoutubeLoader( url, - language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL, - ) - else: - return get_web_loader( - url, - verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV, + language=config.get('youtube_language'), + proxy_url=config.get('youtube_proxy_url'), ) + return get_web_loader( + url, + verify_ssl=config.get('web_loader_ssl_verification'), + requests_per_second=config.get('web_loader_concurrent_requests'), + trust_env=config.get('web_search_trust_env'), + ) -def build_loader_from_config(request): +def build_loader_from_config(request, config: dict): """Build a Loader instance with the admin's configured extraction engine settings.""" from open_webui.retrieval.loaders.main import Loader - config = request.app.state.config + loader_config = { + key: config.get(key) + for key in LOADER_CONFIG_KEYS + if key.isupper() + } return Loader( - engine=config.CONTENT_EXTRACTION_ENGINE, - DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY, - DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL, - DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG, - DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE, - DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR, - DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE, - DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR, - DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES, - DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM, - DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT, - EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL, - EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - TIKA_SERVER_URL=config.TIKA_SERVER_URL, - DOCLING_SERVER_URL=config.DOCLING_SERVER_URL, - DOCLING_API_KEY=config.DOCLING_API_KEY, - DOCLING_PARAMS=config.DOCLING_PARAMS, - PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES, - PDF_LOADER_MODE=config.PDF_LOADER_MODE, - DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT, - DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY, - DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL, - MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL, - MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY, - PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL, - PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN, - MINERU_API_MODE=config.MINERU_API_MODE, - MINERU_API_URL=config.MINERU_API_URL, - MINERU_API_KEY=config.MINERU_API_KEY, - MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT, - MINERU_PARAMS=config.MINERU_PARAMS, - MINERU_FILE_EXTENSIONS=config.MINERU_FILE_EXTENSIONS, + engine=loader_config['CONTENT_EXTRACTION_ENGINE'], + **{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'}, ) -def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]: +def _extract_text_from_binary_response(request, response: requests.Response, url: str, loader_config: dict) -> tuple[str, list]: """Download response body to a temp file and extract text using the Loader pipeline.""" import mimetypes import tempfile @@ -150,7 +170,7 @@ def _extract_text_from_binary_response(request, response: requests.Response, url tmp_path = tmp.name try: - loader = build_loader_from_config(request) + loader = build_loader_from_config(request, loader_config) docs = loader.load(filename, content_type, tmp_path) for doc in docs: doc.metadata['source'] = url @@ -170,9 +190,11 @@ def _is_text_content_type(content_type: str) -> bool: return not ct # empty / missing → assume HTML -def get_content_from_url(request, url: str) -> str: +async def get_content_from_url(request, url: str) -> str: from open_webui.retrieval.web.utils import validate_url + loader_config = await get_loader_config() + # Validate URL before making any request (blocks private IPs, non-HTTP, filter list) validate_url(url) @@ -183,7 +205,7 @@ def get_content_from_url(request, url: str) -> str: # when allow_redirects=False, causing the binary-content path to run # and produce empty docs → HTTP 400. if is_youtube_url(url): - loader = get_loader(request, url) + loader = get_loader(request, url, loader_config) docs = loader.load() content = ' '.join([doc.page_content for doc in docs]) return content, docs @@ -205,14 +227,14 @@ def get_content_from_url(request, url: str) -> str: if response is None or _is_text_content_type(content_type): if response is not None: response.close() - loader = get_loader(request, url) + loader = get_loader(request, url, loader_config) docs = loader.load() content = ' '.join([doc.page_content for doc in docs]) return content, docs # Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract try: - return _extract_text_from_binary_response(request, response, url) + return _extract_text_from_binary_response(request, response, url, loader_config) finally: response.close() @@ -539,8 +561,15 @@ async def query_collection( embedding_function, k: int, ) -> dict: + config = await Config.get_many( + 'rag.enable_hybrid_search', + 'rag.top_k_reranker', + 'rag.relevance_threshold', + 'rag.hybrid_bm25_weight', + 'rag.enable_hybrid_search_enriched_texts', + ) # When request is provided, try hybrid search + reranking if enabled - if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + if request and config.get('rag.enable_hybrid_search'): try: reranking_function = ( (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents)) @@ -553,10 +582,10 @@ async def query_collection( embedding_function=embedding_function, k=k, reranking_function=reranking_function, - k_reranker=request.app.state.config.TOP_K_RERANKER, - r=request.app.state.config.RELEVANCE_THRESHOLD, - hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, - enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + k_reranker=config.get('rag.top_k_reranker'), + r=config.get('rag.relevance_threshold'), + hybrid_bm25_weight=config.get('rag.hybrid_bm25_weight'), + enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'), ) except Exception as e: log.debug(f'Hybrid search failed, falling back to vector search: {e}') @@ -1165,6 +1194,7 @@ async def get_sources_from_items( ): log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}') + bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval') extracted_collections = [] query_results = [] @@ -1244,14 +1274,14 @@ async def get_sources_from_items( } elif item.get('type') == 'url': - content, docs = get_content_from_url(request, item.get('url')) + content, docs = await get_content_from_url(request, item.get('url')) if docs: query_result = { 'documents': [[content]], 'metadatas': [[{'url': item.get('url'), 'name': item.get('url')}]], } elif item.get('type') == 'file': - if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + if item.get('context') == 'full' or bypass_embedding_and_retrieval: if item.get('file', {}).get('data', {}).get('content', ''): # Manual Full Mode Toggle # Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content") @@ -1323,7 +1353,7 @@ async def get_sources_from_items( permission='read', ) ): - if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + if item.get('context') == 'full' or bypass_embedding_and_retrieval: if knowledge_base and ( user.role == 'admin' or knowledge_base.user_id == user.id diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py index 05f2d5d51c8..79f8b7b6004 100644 --- a/backend/open_webui/retrieval/web/perplexity.py +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -38,9 +38,7 @@ def search_perplexity( """ - # Handle ConfigVar object - if hasattr(api_key, '__str__'): - api_key = str(api_key) + api_key = str(api_key) try: url = 'https://api.perplexity.ai/chat/completions' diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py index f3284f9586c..ad38565621c 100644 --- a/backend/open_webui/retrieval/web/perplexity_search.py +++ b/backend/open_webui/retrieval/web/perplexity_search.py @@ -29,12 +29,8 @@ def search_perplexity_search( """ - # Handle ConfigVar object - if hasattr(api_key, '__str__'): - api_key = str(api_key) - - if hasattr(api_url, '__str__'): - api_url = str(api_url) + api_key = str(api_key) + api_url = str(api_url) try: url = api_url diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index c5fa1c07388..eed81560238 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -21,7 +21,6 @@ import aiohttp import aiohttp.resolver import certifi -import requests import urllib3.connection import urllib3.connectionpool import validators @@ -777,13 +776,13 @@ def get_web_loader( 'trust_env': trust_env, } - if WEB_LOADER_ENGINE.value == '' or WEB_LOADER_ENGINE.value == 'safe_web': + if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web': WebLoaderClass = SafeWebBaseLoader request_kwargs = {} - if WEB_LOADER_TIMEOUT.value: + if WEB_LOADER_TIMEOUT: try: - timeout_value = float(WEB_LOADER_TIMEOUT.value) + timeout_value = float(WEB_LOADER_TIMEOUT) except ValueError: timeout_value = None @@ -793,31 +792,31 @@ def get_web_loader( if request_kwargs: web_loader_args['requests_kwargs'] = request_kwargs - if WEB_LOADER_ENGINE.value == 'playwright': + if WEB_LOADER_ENGINE == 'playwright': WebLoaderClass = SafePlaywrightURLLoader - web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT.value - if PLAYWRIGHT_WS_URL.value: - web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL.value + web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT + if PLAYWRIGHT_WS_URL: + web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL - if WEB_LOADER_ENGINE.value == 'firecrawl': + if WEB_LOADER_ENGINE == 'firecrawl': WebLoaderClass = SafeFireCrawlLoader - web_loader_args['api_key'] = FIRECRAWL_API_KEY.value - web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL.value - if FIRECRAWL_TIMEOUT.value: + web_loader_args['api_key'] = FIRECRAWL_API_KEY + web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL + if FIRECRAWL_TIMEOUT: try: - web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT.value) + web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT) except ValueError: pass - if WEB_LOADER_ENGINE.value == 'tavily': + if WEB_LOADER_ENGINE == 'tavily': WebLoaderClass = SafeTavilyLoader - web_loader_args['api_key'] = TAVILY_API_KEY.value - web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH.value + web_loader_args['api_key'] = TAVILY_API_KEY + web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH - if WEB_LOADER_ENGINE.value == 'external': + if WEB_LOADER_ENGINE == 'external': WebLoaderClass = ExternalWebLoader - web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL.value - web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY.value + web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL + web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY if WebLoaderClass: web_loader = WebLoaderClass(**web_loader_args) @@ -831,6 +830,6 @@ def get_web_loader( return web_loader else: raise ValueError( - f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. ' + f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. ' "Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'." ) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index d6d3c362737..83177483b11 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -52,6 +52,7 @@ ENABLE_FORWARD_USER_INFO_HEADERS, ENV, ) +from open_webui.models.config import Config from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import include_user_info_headers @@ -71,6 +72,50 @@ SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech' SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) +TTS_CONFIG_KEYS = { + 'OPENAI_API_BASE_URL': 'audio.tts.openai.api_base_url', + 'OPENAI_API_KEY': 'audio.tts.openai.api_key', + 'OPENAI_PARAMS': 'audio.tts.openai.params', + 'API_KEY': 'audio.tts.api_key', + 'ENGINE': 'audio.tts.engine', + 'MODEL': 'audio.tts.model', + 'VOICE': 'audio.tts.voice', + 'SPLIT_ON': 'audio.tts.split_on', + 'AZURE_SPEECH_REGION': 'audio.tts.azure.speech_region', + 'AZURE_SPEECH_BASE_URL': 'audio.tts.azure.speech_base_url', + 'AZURE_SPEECH_OUTPUT_FORMAT': 'audio.tts.azure.speech_output_format', + 'MISTRAL_API_KEY': 'audio.tts.mistral.api_key', + 'MISTRAL_API_BASE_URL': 'audio.tts.mistral.api_base_url', +} + +STT_CONFIG_KEYS = { + 'OPENAI_API_BASE_URL': 'audio.stt.openai.api_base_url', + 'OPENAI_API_KEY': 'audio.stt.openai.api_key', + 'ENGINE': 'audio.stt.engine', + 'MODEL': 'audio.stt.model', + 'SUPPORTED_CONTENT_TYPES': 'audio.stt.supported_content_types', + 'ALLOWED_EXTENSIONS': 'audio.stt.allowed_extensions', + 'WHISPER_MODEL': 'audio.stt.whisper_model', + 'DEEPGRAM_API_KEY': 'audio.stt.deepgram.api_key', + 'AZURE_API_KEY': 'audio.stt.azure.api_key', + 'AZURE_REGION': 'audio.stt.azure.region', + 'AZURE_LOCALES': 'audio.stt.azure.locales', + 'AZURE_BASE_URL': 'audio.stt.azure.base_url', + 'AZURE_MAX_SPEAKERS': 'audio.stt.azure.max_speakers', + 'MISTRAL_API_KEY': 'audio.stt.mistral.api_key', + 'MISTRAL_API_BASE_URL': 'audio.stt.mistral.api_base_url', + 'MISTRAL_USE_CHAT_COMPLETIONS': 'audio.stt.mistral.use_chat_completions', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +def config_updates(data: dict, key_map: dict[str, str]) -> dict: + return {key_map[field]: value for field, value in data.items() if field in key_map} + def is_audio_conversion_required(file_path): """ @@ -228,119 +273,28 @@ class AudioConfigUpdateForm(BaseModel): @router.get('/config') async def get_audio_config(request: Request, user=Depends(get_admin_user)): return { - 'tts': { - 'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL, - 'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY, - 'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS, - 'API_KEY': request.app.state.config.TTS_API_KEY, - 'ENGINE': request.app.state.config.TTS_ENGINE, - 'MODEL': request.app.state.config.TTS_MODEL, - 'VOICE': request.app.state.config.TTS_VOICE, - 'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON, - 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, - 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, - 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, - 'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY, - 'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL, - }, - 'stt': { - 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, - 'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY, - 'ENGINE': request.app.state.config.STT_ENGINE, - 'MODEL': request.app.state.config.STT_MODEL, - 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, - 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, - 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, - 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, - 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, - 'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION, - 'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES, - 'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL, - 'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, - 'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, - 'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, - 'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, - }, + 'tts': await get_config_values(TTS_CONFIG_KEYS), + 'stt': await get_config_values(STT_CONFIG_KEYS), } @router.post('/config/update') async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)): - # TTS settings - request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL - request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY - request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS - request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY - request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE - request.app.state.config.TTS_MODEL = form_data.tts.MODEL - request.app.state.config.TTS_VOICE = form_data.tts.VOICE - request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON - request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION - request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL - request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT - request.app.state.config.TTS_MISTRAL_API_KEY = form_data.tts.MISTRAL_API_KEY - request.app.state.config.TTS_MISTRAL_API_BASE_URL = form_data.tts.MISTRAL_API_BASE_URL - - # STT settings - request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL - request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY - request.app.state.config.STT_ENGINE = form_data.stt.ENGINE - request.app.state.config.STT_MODEL = form_data.stt.MODEL - request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES - request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS - request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL - request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY - request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY - request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION - request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES - request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL - request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = form_data.stt.AZURE_MAX_SPEAKERS - request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY - request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = form_data.stt.MISTRAL_API_BASE_URL - request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS - - if request.app.state.config.STT_ENGINE == '': + await Config.upsert( + { + **config_updates(form_data.tts.model_dump(), TTS_CONFIG_KEYS), + **config_updates(form_data.stt.model_dump(), STT_CONFIG_KEYS), + } + ) + + if form_data.stt.ENGINE == '': request.app.state.faster_whisper_model = set_faster_whisper_model( form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE ) else: request.app.state.faster_whisper_model = None - return { - 'tts': { - 'ENGINE': request.app.state.config.TTS_ENGINE, - 'MODEL': request.app.state.config.TTS_MODEL, - 'VOICE': request.app.state.config.TTS_VOICE, - 'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL, - 'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY, - 'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS, - 'API_KEY': request.app.state.config.TTS_API_KEY, - 'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON, - 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, - 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, - 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, - 'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY, - 'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL, - }, - 'stt': { - 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, - 'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY, - 'ENGINE': request.app.state.config.STT_ENGINE, - 'MODEL': request.app.state.config.STT_MODEL, - 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, - 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, - 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, - 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, - 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, - 'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION, - 'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES, - 'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL, - 'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, - 'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, - 'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, - 'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, - }, - } + return await get_audio_config(request, user) def load_speech_pipeline(request): @@ -388,14 +342,16 @@ async def _write_tts_cache( async def _tts_openai(request, payload, file_path, file_body_path, user): """Generate speech via an OpenAI-compatible TTS endpoint.""" - payload['model'] = request.app.state.config.TTS_MODEL + payload['model'] = await Config.get('audio.tts.model') if not payload.get('voice'): - payload['voice'] = request.app.state.config.TTS_VOICE - payload = {**payload, **(request.app.state.config.TTS_OPENAI_PARAMS or {})} + payload['voice'] = await Config.get('audio.tts.voice') + payload = {**payload, **(await Config.get('audio.tts.openai.params') or {})} + api_key = await Config.get('audio.tts.openai.api_key') + api_base_url = await Config.get('audio.tts.openai.api_base_url') headers = { 'Content-Type': 'application/json', - 'Authorization': f'Bearer {request.app.state.config.TTS_OPENAI_API_KEY}', + 'Authorization': f'Bearer {api_key}', } if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) @@ -404,7 +360,7 @@ async def _tts_openai(request, payload, file_path, file_body_path, user): try: session = await get_session() r = await session.post( - url=f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech', + url=f'{api_base_url}/audio/speech', json=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -444,13 +400,13 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user): f'{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}', json={ 'text': payload['input'], - 'model_id': request.app.state.config.TTS_MODEL, + 'model_id': await Config.get('audio.tts.model'), 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5}, }, headers={ 'Accept': 'audio/mpeg', 'Content-Type': 'application/json', - 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'xi-api-key': await Config.get('audio.tts.api_key'), }, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: @@ -464,11 +420,11 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user): async def _tts_azure(request, payload, file_path, file_body_path, user): """Generate speech via Azure Cognitive Services TTS.""" - az_region = request.app.state.config.TTS_AZURE_SPEECH_REGION or 'eastus' - az_base = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL - language = payload.get('voice') or request.app.state.config.TTS_VOICE + az_region = await Config.get('audio.tts.azure.speech_region') or 'eastus' + az_base = await Config.get('audio.tts.azure.speech_base_url') + language = payload.get('voice') or await Config.get('audio.tts.voice') locale = '-'.join(language.split('-')[:2]) - output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT + output_format = await Config.get('audio.tts.azure.speech_output_format') ssml = ( f'' @@ -482,7 +438,7 @@ async def _tts_azure(request, payload, file_path, file_body_path, user): async with session.post( (az_base or f'https://{az_region}.tts.speech.microsoft.com') + '/cognitiveservices/v1', headers={ - 'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY, + 'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key'), 'Content-Type': 'application/ssml+xml', 'X-Microsoft-OutputFormat': output_format, }, @@ -505,7 +461,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user): load_speech_pipeline(request) embeddings = request.app.state.speech_speaker_embeddings_dataset - model_name = request.app.state.config.TTS_MODEL + model_name = await Config.get('audio.tts.model') idx = 6799 try: @@ -533,8 +489,8 @@ def _run_pipeline(): async def _tts_mistral(request, payload, file_path, file_body_path, user): """Generate speech via the Mistral TTS API.""" - api_key = request.app.state.config.TTS_MISTRAL_API_KEY - api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' + api_key = await Config.get('audio.tts.mistral.api_key') + api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1' if not api_key: raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral TTS') @@ -546,7 +502,7 @@ async def _tts_mistral(request, payload, file_path, file_body_path, user): url=f'{api_base_url}/audio/speech', json={ 'input': payload.get('input', ''), # text to synthesize - 'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603', + 'model': await Config.get('audio.tts.model') or 'voxtral-mini-tts-2603', 'voice_id': payload.get('voice', ''), 'response_format': 'mp3', }, @@ -582,7 +538,7 @@ async def _tts_mistral(request, payload, file_path, file_body_path, user): @router.post('/speech') async def speech(request: Request, user=Depends(get_verified_user)): - engine = request.app.state.config.TTS_ENGINE + engine = await Config.get('audio.tts.engine') if engine == '': raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -590,7 +546,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): ) if user.role != 'admin' and not await has_permission( - user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS + user.id, 'chat.tts', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -599,7 +555,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256( - body + str(engine).encode('utf-8') + str(request.app.state.config.TTS_MODEL).encode('utf-8') + body + str(engine).encode('utf-8') + str(await Config.get('audio.tts.model')).encode('utf-8') ).hexdigest() file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3') @@ -624,7 +580,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): async def _transcribe_whisper(request, file_path, languages, file_dir, id): if request.app.state.faster_whisper_model is None: - request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL) + request.app.state.faster_whisper_model = set_faster_whisper_model(await Config.get('audio.stt.whisper_model')) model = request.app.state.faster_whisper_model @@ -655,11 +611,13 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, try: session = await get_session() for language in languages: - payload = {'model': request.app.state.config.STT_MODEL} + payload = {'model': await Config.get('audio.stt.model')} if language: payload['language'] = language + api_key = await Config.get('audio.stt.openai.api_key') + api_base_url = await Config.get('audio.stt.openai.api_base_url') - headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'} + headers = {'Authorization': f'Bearer {api_key}'} if user and ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) @@ -669,7 +627,7 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, form_data.add_field('file', open(file_path, 'rb'), filename=filename) r = await session.post( - url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions', + url=f'{api_base_url}/audio/transcriptions', headers=headers, data=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -703,8 +661,8 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id): async with aiofiles.open(file_path, 'rb') as f: audio_bytes = await f.read() - api_key = request.app.state.config.DEEPGRAM_API_KEY - stt_model = request.app.state.config.STT_MODEL + api_key = await Config.get('audio.stt.deepgram.api_key') + stt_model = await Config.get('audio.stt.model') r = None try: @@ -771,11 +729,11 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): detail=f'File size ({audio_size // (1024 * 1024)}MB) exceeds Azure limit of {AZURE_MAX_FILE_SIZE_MB}MB', ) - api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY - region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus' - locale_str = request.app.state.config.AUDIO_STT_AZURE_LOCALES - base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL - max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3 + api_key = await Config.get('audio.stt.azure.api_key') + region = await Config.get('audio.stt.azure.region') or 'eastus' + locale_str = await Config.get('audio.stt.azure.locales') + base_url = await Config.get('audio.stt.azure.base_url') + max_speakers = await Config.get('audio.stt.azure.max_speakers') or 3 # Default to a broad set of locales when none are configured if len(locale_str) < 2: @@ -885,16 +843,16 @@ async def transcription_handler(request, file_path, metadata, user=None): None, # Always fallback to None in case transcription fails ] - if request.app.state.config.STT_ENGINE == '': + if await Config.get('audio.stt.engine') == '': return await _transcribe_whisper(request, file_path, languages, file_dir, id) - elif request.app.state.config.STT_ENGINE == 'openai': + elif await Config.get('audio.stt.engine') == 'openai': return await _transcribe_openai(request, file_path, filename, languages, file_dir, id, user) - elif request.app.state.config.STT_ENGINE == 'deepgram': + elif await Config.get('audio.stt.engine') == 'deepgram': return await _transcribe_deepgram(request, file_path, languages, file_dir, id) - elif request.app.state.config.STT_ENGINE == 'azure': + elif await Config.get('audio.stt.engine') == 'azure': return await _transcribe_azure(request, file_path, filename, file_dir, id) - elif request.app.state.config.STT_ENGINE == 'mistral': + elif await Config.get('audio.stt.engine') == 'mistral': return await _transcribe_mistral(request, file_path, filename, metadata, file_dir, id) @@ -907,16 +865,16 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, if file_size > MAX_FILE_SIZE: raise HTTPException(status_code=400, detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB') - api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY - api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' - use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS + api_key = await Config.get('audio.stt.mistral.api_key') + api_base_url = await Config.get('audio.stt.mistral.api_base_url') or 'https://api.mistral.ai/v1' + use_chat_completions = await Config.get('audio.stt.mistral.use_chat_completions') if not api_key: raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral STT') r = None try: - model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest' + model = await Config.get('audio.stt.model') or 'voxtral-mini-latest' log.info( f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}' ) @@ -1158,14 +1116,14 @@ async def transcription( user=Depends(get_verified_user), ): if user.role != 'admin' and not await has_permission( - user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS + user.id, 'chat.stt', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) log.info(f'file.content_type: {file.content_type}') - stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) + stt_supported_content_types = await Config.get('audio.stt.supported_content_types', []) if not strict_match_mime_type(stt_supported_content_types, file.content_type): raise HTTPException( @@ -1177,7 +1135,7 @@ async def transcription( safe_name = os.path.basename(file.filename) if file.filename else '' ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else '' - allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', []) + allowed_extensions = await Config.get('audio.stt.allowed_extensions', []) if allowed_extensions and ext not in allowed_extensions: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1237,11 +1195,11 @@ async def transcription( async def get_available_models(request: Request) -> list[dict]: """Return the list of available TTS models for the configured engine.""" available_models = [] - engine = request.app.state.config.TTS_ENGINE + engine = await Config.get('audio.tts.engine') _timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) if engine == 'openai': - base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL + base_url = await Config.get('audio.tts.openai.api_base_url') if not base_url.startswith('https://api.openai.com'): session = await get_session() try: @@ -1276,7 +1234,7 @@ async def get_available_models(request: Request) -> list[dict]: async with session.get( f'{ELEVENLABS_API_BASE_URL}/v1/models', headers={ - 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'xi-api-key': await Config.get('audio.tts.api_key'), 'Content-Type': 'application/json', }, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -1311,11 +1269,11 @@ async def get_models(request: Request, user=Depends(get_verified_user)): async def get_available_voices(request) -> dict: """Return ``{voice_id: voice_name}`` for the configured TTS engine.""" - engine = request.app.state.config.TTS_ENGINE + engine = await Config.get('audio.tts.engine') _timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) if engine == 'openai': - base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL + base_url = await Config.get('audio.tts.openai.api_base_url') if not base_url.startswith('https://api.openai.com'): try: session = await get_session() @@ -1338,7 +1296,7 @@ async def get_available_voices(request) -> dict: async with session.get( f'{ELEVENLABS_API_BASE_URL}/v1/voices', headers={ - 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'xi-api-key': await Config.get('audio.tts.api_key'), 'Content-Type': 'application/json', }, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -1353,14 +1311,14 @@ async def get_available_voices(request) -> dict: if engine == 'azure': try: - region = request.app.state.config.TTS_AZURE_SPEECH_REGION - base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL + region = await Config.get('audio.tts.azure.speech_region') + base_url = await Config.get('audio.tts.azure.speech_base_url') url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list' session = await get_session() async with session.get( url, - headers={'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY}, + headers={'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key')}, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout, ) as resp: @@ -1372,8 +1330,8 @@ async def get_available_voices(request) -> dict: return {} if engine == 'mistral': - api_key = request.app.state.config.TTS_MISTRAL_API_KEY - api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' + api_key = await Config.get('audio.tts.mistral.api_key') + api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1' if api_key: try: session = await get_session() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 5b4ec857a47..d0e06fb3f56 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -8,21 +8,15 @@ import urllib import uuid from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS -from typing import List, Optional from aiohttp import ClientSession from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi.responses import JSONResponse, Response from ldap3 import NONE, Connection, Server, Tls from ldap3.utils.conv import escape_filter_chars from open_webui.config import ( - ENABLE_LDAP, - ENABLE_OAUTH_SIGNUP, ENABLE_PASSWORD_AUTH, - OAUTH_MERGE_ACCOUNTS_BY_EMAIL, OAUTH_PROVIDERS, - OPENID_END_SESSION_ENDPOINT, - OPENID_PROVIDER_URL, ) from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( @@ -50,6 +44,7 @@ Token, UpdatePasswordForm, ) +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import ( @@ -75,7 +70,6 @@ ) from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration, validate_email_format -from open_webui.utils.oauth import auth_manager_config from open_webui.utils.rate_limit import RateLimiter from open_webui.utils.redis import get_redis_client from open_webui.utils.webhook import post_webhook @@ -90,6 +84,60 @@ # who exceed their allotted rate against this gate. signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3) +ADMIN_CONFIG_KEYS = { + 'SHOW_ADMIN_DETAILS': 'auth.admin.show', + 'ADMIN_EMAIL': 'auth.admin.email', + 'WEBUI_URL': 'webui.url', + 'ENABLE_SIGNUP': 'ui.enable_signup', + 'ENABLE_API_KEYS': 'auth.enable_api_keys', + 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': 'auth.api_key.endpoint_restrictions', + 'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints', + 'DEFAULT_USER_ROLE': 'ui.default_user_role', + 'DEFAULT_GROUP_ID': 'ui.default_group_id', + 'JWT_EXPIRES_IN': 'auth.jwt_expiry', + 'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing', + 'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating', + 'ENABLE_FOLDERS': 'folders.enable', + 'FOLDER_MAX_FILE_COUNT': 'folders.max_file_count', + 'AUTOMATION_MAX_COUNT': 'automations.max_count', + 'AUTOMATION_MIN_INTERVAL': 'automations.min_interval', + 'ENABLE_AUTOMATIONS': 'automations.enable', + 'ENABLE_CHANNELS': 'channels.enable', + 'ENABLE_CALENDAR': 'calendar.enable', + 'ENABLE_MEMORIES': 'memories.enable', + 'ENABLE_NOTES': 'notes.enable', + 'ENABLE_USER_WEBHOOKS': 'ui.enable_user_webhooks', + 'ENABLE_USER_STATUS': 'users.enable_status', + 'PENDING_USER_OVERLAY_TITLE': 'ui.pending_user_overlay_title', + 'PENDING_USER_OVERLAY_CONTENT': 'ui.pending_user_overlay_content', + 'RESPONSE_WATERMARK': 'ui.watermark', +} + +LDAP_SERVER_CONFIG_KEYS = { + 'label': 'ldap.server.label', + 'host': 'ldap.server.host', + 'port': 'ldap.server.port', + 'attribute_for_mail': 'ldap.server.attribute_for_mail', + 'attribute_for_username': 'ldap.server.attribute_for_username', + 'app_dn': 'ldap.server.app_dn', + 'app_dn_password': 'ldap.server.app_password', + 'search_base': 'ldap.server.users_dn', + 'search_filters': 'ldap.server.search_filter', + 'use_tls': 'ldap.server.use_tls', + 'certificate_path': 'ldap.server.ca_cert_file', + 'validate_cert': 'ldap.server.validate_cert', + 'ciphers': 'ldap.server.ciphers', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +def config_updates(data: dict, key_map: dict[str, str]) -> dict: + return {key_map[field]: value for field, value in data.items() if field in key_map} + async def create_session_response( request: Request, user, db, response: Response = None, set_cookie: bool = False @@ -105,7 +153,7 @@ async def create_session_response( response: FastAPI response object (required if set_cookie is True) set_cookie: Whether to set the auth cookie on the response """ - expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_delta = parse_duration(await Config.get('auth.jwt_expiry')) expires_at = None if expires_delta: expires_at = int(time.time()) + int(expires_delta.total_seconds()) @@ -128,7 +176,7 @@ async def create_session_response( **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db) return { 'token': token, @@ -201,7 +249,7 @@ async def get_session_user( **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db) response_data = { 'token': token, @@ -320,7 +368,7 @@ async def ldap_auth( db: AsyncSession = Depends(get_async_session), ): # Security checks FIRST - before loading any config - if not request.app.state.config.ENABLE_LDAP: + if not await Config.get('ldap.enable'): raise HTTPException(400, detail='LDAP authentication is not enabled') if not ENABLE_PASSWORD_AUTH: @@ -338,19 +386,19 @@ async def ldap_auth( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # NOW load LDAP config variables - LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL - LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST - LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT - LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL - LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME - LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE - LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS - LDAP_APP_DN = request.app.state.config.LDAP_APP_DN - LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD - LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS - LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE - LDAP_VALIDATE_CERT = CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE - LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL' + LDAP_SERVER_LABEL = await Config.get('ldap.server.label') + LDAP_SERVER_HOST = await Config.get('ldap.server.host') + LDAP_SERVER_PORT = await Config.get('ldap.server.port') + LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail') + LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username') + LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn') + LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter') + LDAP_APP_DN = await Config.get('ldap.server.app_dn') + LDAP_APP_PASSWORD = await Config.get('ldap.server.app_password') + LDAP_USE_TLS = await Config.get('ldap.server.use_tls') + LDAP_CA_CERT_FILE = await Config.get('ldap.server.ca_cert_file') + LDAP_VALIDATE_CERT = CERT_REQUIRED if await Config.get('ldap.server.validate_cert') else CERT_NONE + LDAP_CIPHERS = await Config.get('ldap.server.ciphers') if await Config.get('ldap.server.ciphers') else 'ALL' try: tls = Tls( @@ -381,9 +429,9 @@ async def ldap_auth( if not await asyncio.to_thread(connection_app.bind): raise HTTPException(400, detail='Application account bind failed') - ENABLE_LDAP_GROUP_MANAGEMENT = request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT - ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION - LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS + ENABLE_LDAP_GROUP_MANAGEMENT = await Config.get('ldap.group.enable_management') + ENABLE_LDAP_GROUP_CREATION = await Config.get('ldap.group.enable_creation') + LDAP_ATTRIBUTE_FOR_GROUPS = await Config.get('ldap.server.attribute_for_groups') search_attributes = [ f'{LDAP_ATTRIBUTE_FOR_USERNAME}', @@ -500,7 +548,7 @@ async def ldap_auth( email=email, password=str(uuid.uuid4()), name=cn, - role=request.app.state.config.DEFAULT_USER_ROLE, + role=await Config.get('ui.default_user_role'), db=db, ) @@ -514,15 +562,15 @@ async def ldap_auth( user = await Users.get_user_by_id(user.id, db=db) await apply_default_group_assignment( - request.app.state.config.DEFAULT_GROUP_ID, + await Config.get('ui.default_group_id'), user.id, db=db, ) - if request.app.state.config.WEBHOOK_URL: + if await Config.get('webhook_url'): await post_webhook( request.app.state.WEBUI_NAME, - request.app.state.config.WEBHOOK_URL, + await Config.get('webhook_url'), WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { 'action': 'signup', @@ -703,7 +751,7 @@ async def signup_handler( password=hashed, name=name, profile_image_url=profile_image_url, - role=request.app.state.config.DEFAULT_USER_ROLE, + role=await Config.get('ui.default_user_role'), db=db, ) if not user: @@ -714,12 +762,12 @@ async def signup_handler( if await Users.get_num_users(db=db) == 1: await Users.update_user_role_by_id(user.id, 'admin', db=db) user = await Users.get_user_by_id(user.id, db=db) - request.app.state.config.ENABLE_SIGNUP = False + await Config.upsert({'ui.enable_signup': False}) - if request.app.state.config.WEBHOOK_URL: + if await Config.get('webhook_url'): await post_webhook( request.app.state.WEBUI_NAME, - request.app.state.config.WEBHOOK_URL, + await Config.get('webhook_url'), WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { 'action': 'signup', @@ -729,7 +777,7 @@ async def signup_handler( ) await apply_default_group_assignment( - request.app.state.config.DEFAULT_GROUP_ID, + await Config.get('ui.default_group_id'), user.id, db=db, ) @@ -748,10 +796,10 @@ async def signup( if WEBUI_AUTH: if has_users: - if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM: + if not await Config.get('ui.enable_signup') or not await Config.get('ui.enable_login_form'): raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) # Don't gate the first admin on ENABLE_SIGNUP: it auto-disables and can persist stale across a DB reset. - elif not request.app.state.config.ENABLE_LOGIN_FORM and not ENABLE_INITIAL_ADMIN_SIGNUP: + elif not await Config.get('ui.enable_login_form') and not ENABLE_INITIAL_ADMIN_SIGNUP: raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) else: if has_users: @@ -812,19 +860,21 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen # If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect # there directly instead of attempting OIDC discovery. - if OPENID_END_SESSION_ENDPOINT.value: + openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint') + if openid_end_session_endpoint: return JSONResponse( status_code=200, content={ 'status': True, - 'redirect_url': OPENID_END_SESSION_ENDPOINT.value, + 'redirect_url': openid_end_session_endpoint, }, headers=response.headers, ) + openid_provider_url = await Config.get('oauth.provider_url') oauth_server_metadata_url = ( request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None - ) or OPENID_PROVIDER_URL.value + ) or openid_provider_url if session and oauth_server_metadata_url: oauth_id_token = session.token.get('id_token') @@ -934,12 +984,12 @@ async def add_user( if user: await apply_default_group_assignment( - request.app.state.config.DEFAULT_GROUP_ID, + await Config.get('ui.default_group_id'), user.id, db=db, ) - expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_delta = parse_duration(await Config.get('auth.jwt_expiry')) token = create_token(data={'id': user.id}, expires_delta=expires_delta) return { 'token': token, @@ -968,8 +1018,8 @@ async def add_user( async def get_admin_details( request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) ): - if request.app.state.config.SHOW_ADMIN_DETAILS: - admin_email = request.app.state.config.ADMIN_EMAIL + if await Config.get('auth.admin.show'): + admin_email = await Config.get('auth.admin.email') admin_name = None log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}') @@ -999,34 +1049,7 @@ async def get_admin_details( @router.get('/admin/config') async def get_admin_config(request: Request, user=Depends(get_admin_user)): - return { - 'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS, - 'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL, - 'WEBUI_URL': request.app.state.config.WEBUI_URL, - 'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP, - 'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS, - 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, - 'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, - 'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE, - 'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID, - '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_FOLDERS': request.app.state.config.ENABLE_FOLDERS, - 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, - 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, - 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, - 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, - 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, - 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, - 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, - 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, - 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, - 'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS, - 'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE, - 'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT, - 'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK, - } + return await get_config_values(ADMIN_CONFIG_KEYS) class AdminConfig(BaseModel): @@ -1060,81 +1083,24 @@ class AdminConfig(BaseModel): @router.post('/admin/config') async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)): - request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS - request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL - request.app.state.config.WEBUI_URL = form_data.WEBUI_URL - request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP - - request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS - request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS - request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = form_data.API_KEYS_ALLOWED_ENDPOINTS - - request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS - request.app.state.config.FOLDER_MAX_FILE_COUNT = ( - int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' - ) - request.app.state.config.AUTOMATION_MAX_COUNT = ( - int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' - ) - request.app.state.config.AUTOMATION_MIN_INTERVAL = ( + updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS) + updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' + updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' + updates['automations.min_interval'] = ( int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else '' ) - request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS - request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS - request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR - request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES - request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES - - if form_data.DEFAULT_USER_ROLE in ['pending', 'user', 'admin']: - request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE - request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID + if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']: + updates.pop('ui.default_user_role', None) pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$' # Check if the input string matches the pattern - if re.match(pattern, form_data.JWT_EXPIRES_IN): - request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN - - request.app.state.config.ENABLE_COMMUNITY_SHARING = form_data.ENABLE_COMMUNITY_SHARING - request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING - - request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS - request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS + if not re.match(pattern, form_data.JWT_EXPIRES_IN): + updates.pop('auth.jwt_expiry', None) - request.app.state.config.PENDING_USER_OVERLAY_TITLE = form_data.PENDING_USER_OVERLAY_TITLE - request.app.state.config.PENDING_USER_OVERLAY_CONTENT = form_data.PENDING_USER_OVERLAY_CONTENT - - request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK - - return { - 'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS, - 'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL, - 'WEBUI_URL': request.app.state.config.WEBUI_URL, - 'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP, - 'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS, - 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, - 'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, - 'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE, - 'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID, - '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_FOLDERS': request.app.state.config.ENABLE_FOLDERS, - 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, - 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, - 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, - 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, - 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, - 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, - 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, - 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, - 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, - 'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS, - 'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE, - 'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT, - 'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK, - } + await Config.upsert(updates) + return await get_config_values(ADMIN_CONFIG_KEYS) class LdapServerConfig(BaseModel): @@ -1155,21 +1121,7 @@ class LdapServerConfig(BaseModel): @router.get('/admin/config/ldap/server', response_model=LdapServerConfig) async def get_ldap_server(request: Request, user=Depends(get_admin_user)): - return { - 'label': request.app.state.config.LDAP_SERVER_LABEL, - 'host': request.app.state.config.LDAP_SERVER_HOST, - 'port': request.app.state.config.LDAP_SERVER_PORT, - 'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, - 'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, - 'app_dn': request.app.state.config.LDAP_APP_DN, - 'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD, - 'search_base': request.app.state.config.LDAP_SEARCH_BASE, - 'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS, - 'use_tls': request.app.state.config.LDAP_USE_TLS, - 'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE, - 'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT, - 'ciphers': request.app.state.config.LDAP_CIPHERS, - } + return await get_config_values(LDAP_SERVER_CONFIG_KEYS) @router.post('/admin/config/ldap/server') @@ -1186,40 +1138,16 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user if not value: raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key)) - request.app.state.config.LDAP_SERVER_LABEL = form_data.label - request.app.state.config.LDAP_SERVER_HOST = form_data.host - request.app.state.config.LDAP_SERVER_PORT = form_data.port - request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail - request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username - request.app.state.config.LDAP_APP_DN = form_data.app_dn or '' - request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or '' - request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base - request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters - request.app.state.config.LDAP_USE_TLS = form_data.use_tls - request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path - request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert - request.app.state.config.LDAP_CIPHERS = form_data.ciphers - - return { - 'label': request.app.state.config.LDAP_SERVER_LABEL, - 'host': request.app.state.config.LDAP_SERVER_HOST, - 'port': request.app.state.config.LDAP_SERVER_PORT, - 'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, - 'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, - 'app_dn': request.app.state.config.LDAP_APP_DN, - 'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD, - 'search_base': request.app.state.config.LDAP_SEARCH_BASE, - 'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS, - 'use_tls': request.app.state.config.LDAP_USE_TLS, - 'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE, - 'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT, - 'ciphers': request.app.state.config.LDAP_CIPHERS, - } + updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS) + updates['ldap.server.app_dn'] = form_data.app_dn or '' + updates['ldap.server.app_password'] = form_data.app_dn_password or '' + await Config.upsert(updates) + return await get_config_values(LDAP_SERVER_CONFIG_KEYS) @router.get('/admin/config/ldap') async def get_ldap_config(request: Request, user=Depends(get_admin_user)): - return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP} + return {'ENABLE_LDAP': await Config.get('ldap.enable')} class LdapConfigForm(BaseModel): @@ -1228,8 +1156,8 @@ class LdapConfigForm(BaseModel): @router.post('/admin/config/ldap') async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)): - request.app.state.config.ENABLE_LDAP = form_data.enable_ldap - return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP} + await Config.upsert({'ldap.enable': form_data.enable_ldap}) + return {'ENABLE_LDAP': await Config.get('ldap.enable')} ############################ @@ -1237,11 +1165,148 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D ############################ +class OAuthConfigForm(BaseModel): + """All OAuth/OIDC settings exposed to the admin panel.""" + + # General OAuth + ENABLE_OAUTH_SIGNUP: bool | None = None + OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None + OAUTH_AUTO_REDIRECT: bool | None = None + OAUTH_ALLOWED_DOMAINS: str | None = None + OAUTH_BLOCKED_GROUPS: str | None = None + + # Role management + ENABLE_OAUTH_ROLE_MANAGEMENT: bool | None = None + OAUTH_ROLES_CLAIM: str | None = None + OAUTH_ADMIN_ROLES: str | None = None + OAUTH_ALLOWED_ROLES: str | None = None + + # Group management + ENABLE_OAUTH_GROUP_MANAGEMENT: bool | None = None + ENABLE_OAUTH_GROUP_CREATION: bool | None = None + OAUTH_GROUP_CLAIM: str | None = None + OAUTH_GROUP_DEFAULT_SHARE: bool | str | None = None + + # OIDC provider settings + OAUTH_PROVIDER_NAME: str | None = None + OPENID_PROVIDER_URL: str | None = None + OAUTH_CLIENT_ID: str | None = None + OAUTH_CLIENT_SECRET: str | None = None + OPENID_REDIRECT_URI: str | None = None + OAUTH_SCOPES: str | None = None + OAUTH_CODE_CHALLENGE_METHOD: str | None = None + OAUTH_TOKEN_ENDPOINT_AUTH_METHOD: str | None = None + OPENID_END_SESSION_ENDPOINT: str | None = None + OAUTH_TIMEOUT: int | str | None = None + OAUTH_CLIENT_TIMEOUT: int | str | None = None + + # Claims + OAUTH_EMAIL_CLAIM: str | None = None + OAUTH_USERNAME_CLAIM: str | None = None + OAUTH_PICTURE_CLAIM: str | None = None + OAUTH_SUB_CLAIM: str | None = None + OAUTH_AUDIENCE: str | None = None + + # Profile update toggles + OAUTH_UPDATE_EMAIL_ON_LOGIN: bool | None = None + OAUTH_UPDATE_NAME_ON_LOGIN: bool | None = None + OAUTH_UPDATE_PICTURE_ON_LOGIN: bool | None = None + + # Token + OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE: bool | None = None + + +OAUTH_COMMA_LIST_FIELDS = { + 'OAUTH_ALLOWED_DOMAINS', + 'OAUTH_ADMIN_ROLES', + 'OAUTH_ALLOWED_ROLES', +} + + +OAUTH_CONFIG_KEYS = { + 'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup', + 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email', + 'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect', + 'OAUTH_ALLOWED_DOMAINS': 'oauth.allowed_domains', + 'OAUTH_BLOCKED_GROUPS': 'oauth.blocked_groups', + 'ENABLE_OAUTH_ROLE_MANAGEMENT': 'oauth.enable_role_mapping', + 'OAUTH_ROLES_CLAIM': 'oauth.roles_claim', + 'OAUTH_ADMIN_ROLES': 'oauth.admin_roles', + 'OAUTH_ALLOWED_ROLES': 'oauth.allowed_roles', + 'ENABLE_OAUTH_GROUP_MANAGEMENT': 'oauth.enable_group_mapping', + 'ENABLE_OAUTH_GROUP_CREATION': 'oauth.enable_group_creation', + 'OAUTH_GROUP_CLAIM': 'oauth.group_claim', + 'OAUTH_GROUP_DEFAULT_SHARE': 'oauth.group_default_share', + 'OAUTH_PROVIDER_NAME': 'oauth.provider_name', + 'OPENID_PROVIDER_URL': 'oauth.provider_url', + 'OAUTH_CLIENT_ID': 'oauth.client_id', + 'OAUTH_CLIENT_SECRET': 'oauth.client_secret', + 'OPENID_REDIRECT_URI': 'oauth.redirect_uri', + 'OAUTH_SCOPES': 'oauth.scopes', + 'OAUTH_CODE_CHALLENGE_METHOD': 'oauth.code_challenge_method', + 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD': 'oauth.token_endpoint_auth_method', + 'OPENID_END_SESSION_ENDPOINT': 'oauth.end_session_endpoint', + 'OAUTH_TIMEOUT': 'oauth.timeout', + 'OAUTH_CLIENT_TIMEOUT': 'oauth.client.timeout', + 'OAUTH_EMAIL_CLAIM': 'oauth.email_claim', + 'OAUTH_USERNAME_CLAIM': 'oauth.username_claim', + 'OAUTH_PICTURE_CLAIM': 'oauth.picture_claim', + 'OAUTH_SUB_CLAIM': 'oauth.sub_claim', + 'OAUTH_AUDIENCE': 'oauth.audience', + 'OAUTH_UPDATE_EMAIL_ON_LOGIN': 'oauth.update_email_on_login', + 'OAUTH_UPDATE_NAME_ON_LOGIN': 'oauth.update_name_on_login', + 'OAUTH_UPDATE_PICTURE_ON_LOGIN': 'oauth.update_picture_on_login', + 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': 'oauth.refresh_token.include_scope', +} + + +def _format_oauth_form_value(field: str, value): + if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, list): + return ','.join(str(item) for item in value) + return value + + +def _parse_oauth_update_value(field: str, value): + if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, str): + return [item.strip() for item in value.split(',') if item.strip()] + if field in {'OAUTH_TIMEOUT', 'OAUTH_CLIENT_TIMEOUT'} and value == '': + return '' + return value + + +async def get_oauth_config_values() -> dict: + values = await Config.get_many(*OAUTH_CONFIG_KEYS.values()) + return { + field: _format_oauth_form_value(field, values[storage_key]) + for field, storage_key in OAUTH_CONFIG_KEYS.items() + if storage_key in values + } + + +def oauth_config_updates(data: dict) -> dict: + return { + OAUTH_CONFIG_KEYS[field]: _parse_oauth_update_value(field, value) + for field, value in data.items() + if field in OAUTH_CONFIG_KEYS + } + + +@router.get('/admin/config/oauth', response_model=OAuthConfigForm) +async def get_oauth_config(request: Request, user=Depends(get_admin_user)): + return await get_oauth_config_values() + + +@router.post('/admin/config/oauth', response_model=OAuthConfigForm) +async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)): + await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True))) + return await get_oauth_config_values() + + async def _check_api_key_permission(request: Request, user, db: AsyncSession): - if not request.app.state.config.ENABLE_API_KEYS or ( + if not await Config.get('auth.enable_api_keys') or ( user.role != 'admin' and not await has_permission( - user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.api_keys', await Config.get('user.permissions'), db=db ) ): raise HTTPException( @@ -1354,11 +1419,11 @@ async def token_exchange( ) # Extract user information from the token claims - email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM - username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM + email_claim = await Config.get('oauth.email_claim', 'email') # Get sub claim - sub = user_data.get(request.app.state.config.OAUTH_SUB_CLAIM or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) + sub_claim = await Config.get('oauth.sub_claim') + sub = user_data.get(sub_claim or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) if not sub: log.warning(f'Token exchange failed: sub claim missing from user data') raise HTTPException( @@ -1376,10 +1441,10 @@ async def token_exchange( email = email.lower() # Enforce domain allowlist — same check as the normal OAuth callback - if ( - '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - ): + oauth_allowed_domains = await Config.get('oauth.allowed_domains', []) + if isinstance(oauth_allowed_domains, str): + oauth_allowed_domains = [domain.strip() for domain in oauth_allowed_domains.split(',') if domain.strip()] + if '*' not in oauth_allowed_domains and email.split('@')[-1] not in oauth_allowed_domains: log.warning(f'Token exchange denied: email domain not in allowed domains list') raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -1389,7 +1454,7 @@ async def token_exchange( # Try to find the user by OAuth sub user = await Users.get_user_by_oauth_sub(provider, sub, db=db) - if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value: + if not user and await Config.get('oauth.merge_accounts_by_email'): # Try to find by email if merge is enabled user = await Users.get_user_by_email(email, db=db) if user: diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index fced12978ac..b7f6b05e1d2 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -14,6 +14,7 @@ AutomationRuns, Automations, ) +from open_webui.models.config import Config from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.automations import ( @@ -38,13 +39,14 @@ async def check_automations_permission(request, user): - if not request.app.state.config.ENABLE_AUTOMATIONS: + config = await Config.get_many('automations.enable', 'user.permissions') + if not config.get('automations.enable'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED, ) if user.role != 'admin' and not await has_permission( - user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS + user.id, 'features.automations', config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -72,7 +74,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create: # Max count (create only) if is_create: - max_count = request.app.state.config.AUTOMATION_MAX_COUNT + max_count = await Config.get('automations.max_count') if max_count: max_count = int(max_count) if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count: @@ -82,7 +84,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create: ) # Min interval (create + update) - min_interval = request.app.state.config.AUTOMATION_MIN_INTERVAL + min_interval = await Config.get('automations.min_interval') if min_interval: min_interval = int(min_interval) if min_interval > 0: diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 5d397ea8684..48327eb7ace 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -19,6 +19,7 @@ CalendarUpdateForm, RSVPForm, ) +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.users import UserModel from open_webui.utils.access_control import filter_allowed_access_grants, has_permission @@ -34,13 +35,14 @@ async def check_calendar_permission(request: Request, user): """Check global feature flag AND per-user permission for calendar access.""" - if not request.app.state.config.ENABLE_CALENDAR: + config = await Config.get_many('calendar.enable', 'user.permissions') + if not config.get('calendar.enable'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED, ) if user.role != 'admin' and not await has_permission( - user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS + user.id, 'features.calendar', config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -50,11 +52,12 @@ async def check_calendar_permission(request: Request, user): async def _user_has_automations(request: Request, user) -> bool: """Check if automations feature is available to this user.""" - if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False): + config = await Config.get_many('automations.enable', 'user.permissions') + if not config.get('automations.enable', False): return False if user.role == 'admin': return True - return await has_permission(user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS) + return await has_permission(user.id, 'features.automations', config.get('user.permissions')) async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel: @@ -116,7 +119,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM # could create a calendar with `principal_id='*' permission='read'|'write'`, # making their events readable or writable by any other verified user. form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -373,7 +376,7 @@ async def update_calendar( # publicly readable/writable without the corresponding sharing permission. if form_data.access_grants is not None: form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 706ca58e182..08e44ac7631 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -11,6 +11,7 @@ from open_webui.env import STATIC_DIR from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant +from open_webui.models.config import Config from open_webui.models.channels import ( ChannelForm, ChannelModel, @@ -123,7 +124,7 @@ def get_channel_permitted_group_and_user_ids( async def check_channels_access(request: Request, user: Optional[UserModel] = None): """Dependency to ensure channels are globally enabled.""" - if not request.app.state.config.ENABLE_CHANNELS: + if not await Config.get('channels.enable'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'), @@ -131,7 +132,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No if user: if user.role != 'admin' and not await has_permission( - user.id, 'features.channels', request.app.state.config.USER_PERMISSIONS + user.id, 'features.channels', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -292,7 +293,7 @@ async def create_new_channel( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -663,7 +664,7 @@ async def update_channel_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -857,8 +858,8 @@ async def get_pinned_channel_messages( async def send_notification(request, channel, message, active_user_ids, db=None): name = request.app.state.WEBUI_NAME - webui_url = request.app.state.config.WEBUI_URL - enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS + webui_url = await Config.get('webui.url') + enable_user_webhooks = await Config.get('ui.enable_user_webhooks') users = await get_channel_users_with_access(channel, 'read', db=db) @@ -1009,7 +1010,7 @@ async def model_response_handler(request, channel, message, user, db=None): ) tool_ids = _resolve_model_tool_ids(request.app, model_id) - features = _resolve_model_features(request.app, model_id) + features = await _resolve_model_features(request.app, model_id) filter_ids = _resolve_model_filter_ids(request.app, model_id) # Build full form_data — same shape as frontend POST. diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 497f288818e..48b6a29315d 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -12,6 +12,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.chats import ( AggregateChatStats, ChatBody, @@ -46,7 +47,7 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSession): if user.role != 'admin' and not await has_permission( - user.id, 'chat.import', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'chat.import', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -412,7 +413,7 @@ async def export_chat_stats( user=Depends(get_verified_user), ): # Check if the user has permission to share/export chats - if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING): + if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -461,7 +462,7 @@ async def export_single_chat_stats( Returns ChatStatsExport for the specified chat. """ # Check if the user has permission to share/export chats - if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING): + if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -508,7 +509,7 @@ async def delete_all_user_chats( db: AsyncSession = Depends(get_async_session), ): if user.role == 'user' and not await has_permission( - user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS + user.id, 'chat.delete', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1164,7 +1165,7 @@ async def delete_chat_by_id( return result else: - if not await has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1384,7 +1385,7 @@ async def share_chat_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS + user.id, 'chat.share', await Config.get('user.permissions') ): raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) @@ -1460,7 +1461,7 @@ async def update_shared_chat_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index c40131f41fb..287e9e802be 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -7,7 +7,8 @@ import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request from mcp.shared.auth import OAuthMetadata -from open_webui.config import BannerModel, async_save_config, get_config, save_config +from open_webui.config import BannerModel +from open_webui.models.config import Config from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.models.oauth_sessions import OAuthSessions from open_webui.utils.auth import get_admin_user, get_verified_user @@ -34,6 +35,44 @@ log = logging.getLogger(__name__) +CONNECTIONS_CONFIG_KEYS = { + 'ENABLE_DIRECT_CONNECTIONS': 'direct.enable', + 'ENABLE_BASE_MODELS_CACHE': 'models.base_models_cache', +} +CODE_EXECUTION_CONFIG_KEYS = { + 'ENABLE_CODE_EXECUTION': 'code_execution.enable', + 'CODE_EXECUTION_ENGINE': 'code_execution.engine', + 'CODE_EXECUTION_JUPYTER_URL': 'code_execution.jupyter.url', + 'CODE_EXECUTION_JUPYTER_AUTH': 'code_execution.jupyter.auth', + 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': 'code_execution.jupyter.auth_token', + 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': 'code_execution.jupyter.auth_password', + 'CODE_EXECUTION_JUPYTER_TIMEOUT': 'code_execution.jupyter.timeout', + 'ENABLE_CODE_INTERPRETER': 'code_interpreter.enable', + 'CODE_INTERPRETER_ENGINE': 'code_interpreter.engine', + 'CODE_INTERPRETER_PROMPT_TEMPLATE': 'code_interpreter.prompt_template', + 'CODE_INTERPRETER_JUPYTER_URL': 'code_interpreter.jupyter.url', + 'CODE_INTERPRETER_JUPYTER_AUTH': 'code_interpreter.jupyter.auth', + 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': 'code_interpreter.jupyter.auth_token', + 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': 'code_interpreter.jupyter.auth_password', + 'CODE_INTERPRETER_JUPYTER_TIMEOUT': 'code_interpreter.jupyter.timeout', +} +MODELS_CONFIG_KEYS = { + 'DEFAULT_MODELS': 'ui.default_models', + 'DEFAULT_PINNED_MODELS': 'ui.default_pinned_models', + 'MODEL_ORDER_LIST': 'ui.model_order_list', + 'DEFAULT_MODEL_METADATA': 'models.default_metadata', + 'DEFAULT_MODEL_PARAMS': 'models.default_params', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +def config_updates(data: dict, key_map: dict[str, str]) -> dict: + return {key_map[field]: value for field, value in data.items() if field in key_map} + ############################ # ImportConfig @@ -48,9 +87,8 @@ class ImportConfigForm(BaseModel): @router.post('/import', response_model=dict) async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)): - await async_save_config(form_data.config) - request.app.state.config._sync_to_redis() - return get_config() + await Config.upsert(form_data.config) + return await Config.get_all() ############################ @@ -60,7 +98,12 @@ async def import_config(request: Request, form_data: ImportConfigForm, user=Depe @router.get('/export', response_model=dict) async def export_config(user=Depends(get_admin_user)): - return get_config() + return await Config.get_all() + + +@router.get('/namespace/{namespace}', response_model=dict) +async def get_config_namespace(namespace: str, user=Depends(get_admin_user)): + return await Config.get_namespace(namespace) ############################ @@ -75,10 +118,7 @@ class ConnectionsConfigForm(BaseModel): @router.get('/connections', response_model=ConnectionsConfigForm) async def get_connections_config(request: Request, user=Depends(get_admin_user)): - return { - 'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS, - 'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE, - } + return await get_config_values(CONNECTIONS_CONFIG_KEYS) @router.post('/connections', response_model=ConnectionsConfigForm) @@ -87,13 +127,8 @@ async def set_connections_config( form_data: ConnectionsConfigForm, user=Depends(get_admin_user), ): - request.app.state.config.ENABLE_DIRECT_CONNECTIONS = form_data.ENABLE_DIRECT_CONNECTIONS - request.app.state.config.ENABLE_BASE_MODELS_CACHE = form_data.ENABLE_BASE_MODELS_CACHE - - return { - 'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS, - 'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE, - } + await Config.upsert(config_updates(form_data.model_dump(), CONNECTIONS_CONFIG_KEYS)) + return await get_config_values(CONNECTIONS_CONFIG_KEYS) class OAuthClientRegistrationForm(BaseModel): @@ -167,9 +202,7 @@ class ToolServersConfigForm(BaseModel): @router.get('/tool_servers', response_model=ToolServersConfigForm) async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)): - return { - 'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS, - } + return {'TOOL_SERVER_CONNECTIONS': await Config.get('tool_server.connections')} @router.post('/tool_servers', response_model=ToolServersConfigForm) @@ -178,7 +211,8 @@ async def set_tool_servers_config( form_data: ToolServersConfigForm, user=Depends(get_admin_user), ): - for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: + existing_connections = await Config.get('tool_server.connections', []) or [] + for connection in existing_connections: server_type = connection.get('type', 'openapi') auth_type = connection.get('auth_type', 'none') @@ -193,13 +227,12 @@ async def set_tool_servers_config( pass # Set new tool server connections - request.app.state.config.TOOL_SERVER_CONNECTIONS = [ - connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS - ] + connections = [connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS] + await Config.upsert({'tool_server.connections': connections}) await set_tool_servers(request) - for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: + for connection in connections: server_type = connection.get('type', 'openapi') if server_type == 'mcp': server_id = connection.get('info', {}).get('id') @@ -216,9 +249,7 @@ async def set_tool_servers_config( log.debug(f'Failed to add OAuth client for MCP tool server: {e}') continue - return { - 'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS, - } + return {'TOOL_SERVER_CONNECTIONS': connections} class TerminalServerConnection(BaseModel): @@ -249,9 +280,7 @@ class TerminalServersConfigForm(BaseModel): @router.get('/terminal_servers') async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)): - return { - 'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS, - } + return {'TERMINAL_SERVER_CONNECTIONS': await Config.get('terminal_server.connections')} @router.post('/terminal_servers') @@ -260,15 +289,12 @@ async def set_terminal_servers_config( form_data: TerminalServersConfigForm, user=Depends(get_admin_user), ): - request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [ - connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS - ] + connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS] + await Config.upsert({'terminal_server.connections': connections}) await set_terminal_servers(request) - return { - 'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS, - } + return {'TERMINAL_SERVER_CONNECTIONS': connections} @router.post('/terminal_servers/verify') @@ -518,67 +544,15 @@ 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, - 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, - 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, - 'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, - 'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER, - 'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE, - 'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, - 'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, - 'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, - 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, - 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, - 'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, - } + return await get_config_values(CODE_EXECUTION_CONFIG_KEYS) @router.post('/code_execution', response_model=CodeInterpreterConfigForm) 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 - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = form_data.CODE_EXECUTION_JUPYTER_AUTH - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD - request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = form_data.CODE_EXECUTION_JUPYTER_TIMEOUT - - request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER - request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE - request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = form_data.CODE_INTERPRETER_PROMPT_TEMPLATE - - request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = form_data.CODE_INTERPRETER_JUPYTER_URL - - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = form_data.CODE_INTERPRETER_JUPYTER_AUTH - - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT - - 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, - 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, - 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, - 'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, - 'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER, - 'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE, - 'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, - 'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, - 'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, - 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, - 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, - 'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, - } + await Config.upsert(config_updates(form_data.model_dump(), CODE_EXECUTION_CONFIG_KEYS)) + return await get_config_values(CODE_EXECUTION_CONFIG_KEYS) ############################ @@ -595,35 +569,19 @@ class ModelsConfigForm(BaseModel): @router.get('/models/defaults') async def get_models_defaults(request: Request, user=Depends(get_verified_user)): return { - 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, + 'DEFAULT_MODEL_METADATA': await Config.get('models.default_metadata'), } @router.get('/models', response_model=ModelsConfigForm) async def get_models_config(request: Request, user=Depends(get_admin_user)): - return { - 'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS, - 'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS, - 'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST, - 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, - 'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS, - } + return await get_config_values(MODELS_CONFIG_KEYS) @router.post('/models', response_model=ModelsConfigForm) async def set_models_config(request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)): - request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS - request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS - request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST - request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA - request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS - return { - 'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS, - 'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS, - 'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST, - 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, - 'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS, - } + await Config.upsert(config_updates(form_data.model_dump(), MODELS_CONFIG_KEYS)) + return await get_config_values(MODELS_CONFIG_KEYS) class PromptSuggestion(BaseModel): @@ -642,8 +600,8 @@ async def set_default_suggestions( user=Depends(get_admin_user), ): data = form_data.model_dump() - request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data['suggestions'] - return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS + await Config.upsert({'ui.prompt_suggestions': data['suggestions']}) + return await Config.get('ui.prompt_suggestions') ############################ @@ -662,8 +620,8 @@ async def set_banners( user=Depends(get_admin_user), ): data = form_data.model_dump() - request.app.state.config.BANNERS = data['banners'] - return request.app.state.config.BANNERS + await Config.upsert({'ui.banners': data['banners']}) + return await Config.get('ui.banners') @router.get('/banners', response_model=list[BannerModel]) @@ -671,4 +629,4 @@ async def get_banners( request: Request, user=Depends(get_verified_user), ): - return request.app.state.config.BANNERS + return await Config.get('ui.banners') diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index d1c914f4eed..5f0c48af604 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session +from open_webui.models.config import Config from open_webui.models.feedbacks import ( FeedbackForm, FeedbackIdResponse, @@ -25,6 +26,16 @@ router = APIRouter() +EVALUATION_CONFIG_KEYS = { + 'ENABLE_EVALUATION_ARENA_MODELS': 'evaluation.arena.enable', + 'EVALUATION_ARENA_MODELS': 'evaluation.arena.models', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + # Leaderboard Elo Rating Computation # The judgment has already been rendered with grace; @@ -255,10 +266,7 @@ async def get_model_history( @router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): - return { - 'ENABLE_EVALUATION_ARENA_MODELS': request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS, - 'EVALUATION_ARENA_MODELS': request.app.state.config.EVALUATION_ARENA_MODELS, - } + return await get_config_values(EVALUATION_CONFIG_KEYS) ############################ @@ -277,15 +285,13 @@ async def update_config( form_data: UpdateConfigForm, user=Depends(get_admin_user), ): - config = request.app.state.config + updates = {} if form_data.ENABLE_EVALUATION_ARENA_MODELS is not None: - config.ENABLE_EVALUATION_ARENA_MODELS = form_data.ENABLE_EVALUATION_ARENA_MODELS + updates['evaluation.arena.enable'] = form_data.ENABLE_EVALUATION_ARENA_MODELS if form_data.EVALUATION_ARENA_MODELS is not None: - config.EVALUATION_ARENA_MODELS = form_data.EVALUATION_ARENA_MODELS - return { - 'ENABLE_EVALUATION_ARENA_MODELS': config.ENABLE_EVALUATION_ARENA_MODELS, - 'EVALUATION_ARENA_MODELS': config.EVALUATION_ARENA_MODELS, - } + updates['evaluation.arena.models'] = form_data.EVALUATION_ARENA_MODELS + await Config.upsert(updates) + return await get_config_values(EVALUATION_CONFIG_KEYS) @router.get('/feedbacks/models', response_model=list[str]) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index f5f8df66ddd..c7080bd17c2 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -26,6 +26,7 @@ from open_webui.internal.db import get_async_db_context, get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels +from open_webui.models.config import Config from open_webui.models.chats import Chats from open_webui.models.files import ( FileForm, @@ -123,7 +124,7 @@ async def _process_handler(db_session): if _is_text_file(file_path): content_type = 'text/plain' - stt_supported = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) + stt_supported = await Config.get('audio.stt.supported_content_types', []) if content_type and strict_match_mime_type(stt_supported, content_type): # Audio / STT-supported files → transcribe then index @@ -144,7 +145,7 @@ async def _process_handler(db_session): elif ( content_type and content_type.startswith(('image/', 'video/')) - and request.app.state.config.CONTENT_EXTRACTION_ENGINE != 'external' + and await Config.get('rag.content_extraction_engine') != 'external' ): # Media files without an external extraction engine if content_type.startswith('video/'): @@ -288,12 +289,11 @@ async def upload_file_handler( # Remove the leading dot from the file extension and lowercase it file_extension = file_extension[1:].lower() if file_extension else '' - if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS: - request.app.state.config.ALLOWED_FILE_EXTENSIONS = [ - ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext - ] + allowed_file_extensions = await Config.get('rag.file.allowed_extensions') + if process and allowed_file_extensions: + allowed_file_extensions = [ext for ext in allowed_file_extensions if ext] - if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS: + if file_extension not in allowed_file_extensions: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(f'File type {file_extension} is not allowed'), diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index a53bbccd576..43d3a114980 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -11,6 +11,7 @@ from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session +from open_webui.models.config import Config from open_webui.models.chats import Chats from open_webui.models.folders import ( FolderForm, @@ -42,7 +43,8 @@ async def check_folders_permission(request: Request, user, db=None): """Verify the folders feature is enabled and the user has permission.""" - if request.app.state.config.ENABLE_FOLDERS is False: + config = await Config.get_many('folders.enable', 'user.permissions') + if config.get('folders.enable') is False: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -50,7 +52,7 @@ async def check_folders_permission(request: Request, user, db=None): if user.role != 'admin' and not await has_permission( user.id, 'features.folders', - request.app.state.config.USER_PERMISSIONS, + config.get('user.permissions'), db=db, ): raise HTTPException( @@ -411,7 +413,7 @@ async def update_folder_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, None, @@ -522,7 +524,7 @@ async def delete_folder_by_id( folder_ids = await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder_owner_id, db=db) if await Chats.count_chats_by_folder_ids_and_user_id(folder_ids, folder_owner_id, db=db): chat_delete_permission = await has_permission( - user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'chat.delete', await Config.get('user.permissions'), db=db ) if user.role != 'admin' and not chat_delete_permission: raise HTTPException( diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 716a73738a3..465893580b3 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -9,6 +9,7 @@ import re import uuid from pathlib import Path +from types import SimpleNamespace from typing import Optional from urllib.parse import quote, urlparse @@ -24,6 +25,7 @@ from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS from open_webui.internal.db import get_async_session from open_webui.models.chats import Chats +from open_webui.models.config import Config from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url from open_webui.routers.files import get_file_content_by_id, upload_file_handler from open_webui.utils.access_control import has_permission @@ -50,17 +52,68 @@ router = APIRouter() +IMAGE_CONFIG_KEYS = { + 'ENABLE_IMAGE_GENERATION': 'image_generation.enable', + 'ENABLE_IMAGE_PROMPT_GENERATION': 'image_generation.prompt.enable', + 'IMAGE_GENERATION_ENGINE': 'image_generation.engine', + 'IMAGE_GENERATION_MODEL': 'image_generation.model', + 'IMAGE_SIZE': 'image_generation.size', + 'IMAGE_STEPS': 'image_generation.steps', + 'IMAGES_OPENAI_API_BASE_URL': 'image_generation.openai.api_base_url', + 'IMAGES_OPENAI_API_KEY': 'image_generation.openai.api_key', + 'IMAGES_OPENAI_API_VERSION': 'image_generation.openai.api_version', + 'IMAGES_OPENAI_API_PARAMS': 'image_generation.openai.params', + 'AUTOMATIC1111_BASE_URL': 'image_generation.automatic1111.base_url', + 'AUTOMATIC1111_API_AUTH': 'image_generation.automatic1111.api_auth', + 'AUTOMATIC1111_PARAMS': 'image_generation.automatic1111.api_params', + 'COMFYUI_BASE_URL': 'image_generation.comfyui.base_url', + 'COMFYUI_API_KEY': 'image_generation.comfyui.api_key', + 'COMFYUI_WORKFLOW': 'image_generation.comfyui.workflow', + 'COMFYUI_WORKFLOW_NODES': 'image_generation.comfyui.nodes', + 'IMAGES_GEMINI_API_BASE_URL': 'image_generation.gemini.api_base_url', + 'IMAGES_GEMINI_API_KEY': 'image_generation.gemini.api_key', + 'IMAGES_GEMINI_ENDPOINT_METHOD': 'image_generation.gemini.endpoint_method', + 'ENABLE_IMAGE_EDIT': 'images.edit.enable', + 'IMAGE_EDIT_ENGINE': 'images.edit.engine', + 'IMAGE_EDIT_MODEL': 'images.edit.model', + 'IMAGE_EDIT_SIZE': 'images.edit.size', + 'IMAGES_EDIT_OPENAI_API_BASE_URL': 'images.edit.openai.api_base_url', + 'IMAGES_EDIT_OPENAI_API_KEY': 'images.edit.openai.api_key', + 'IMAGES_EDIT_OPENAI_API_VERSION': 'images.edit.openai.api_version', + 'IMAGES_EDIT_GEMINI_API_BASE_URL': 'images.edit.gemini.api_base_url', + 'IMAGES_EDIT_GEMINI_API_KEY': 'images.edit.gemini.api_key', + 'IMAGES_EDIT_COMFYUI_BASE_URL': 'images.edit.comfyui.base_url', + 'IMAGES_EDIT_COMFYUI_API_KEY': 'images.edit.comfyui.api_key', + 'IMAGES_EDIT_COMFYUI_WORKFLOW': 'images.edit.comfyui.workflow', + 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': 'images.edit.comfyui.nodes', + 'USER_PERMISSIONS': 'user.permissions', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +async def get_image_config() -> SimpleNamespace: + return SimpleNamespace(**await get_config_values(IMAGE_CONFIG_KEYS)) + + +def config_updates(data: dict, key_map: dict[str, str]) -> dict: + return {key_map[field]: value for field, value in data.items() if field in key_map} + async def set_image_model(request: Request, model: str): log.info(f'Setting image model to {model}') - request.app.state.config.IMAGE_GENERATION_MODEL = model - if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: - api_auth = get_automatic1111_api_auth(request) + await Config.upsert({'image_generation.model': model}) + image_config = await get_image_config() + if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: + api_auth = get_automatic1111_api_auth(image_config) try: session = await get_session() async with session.get( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': api_auth}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: @@ -68,7 +121,7 @@ async def set_image_model(request: Request, model: str): if model != options['sd_model_checkpoint']: options['sd_model_checkpoint'] = model async with session.post( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', json=options, headers={'authorization': api_auth}, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -77,35 +130,36 @@ async def set_image_model(request: Request, model: str): except Exception as e: log.debug(f'{e}') - return request.app.state.config.IMAGE_GENERATION_MODEL + return image_config.IMAGE_GENERATION_MODEL async def get_image_model(request): - if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': + image_config = await get_image_config() + if image_config.IMAGE_GENERATION_ENGINE == 'openai': return ( - request.app.state.config.IMAGE_GENERATION_MODEL - if request.app.state.config.IMAGE_GENERATION_MODEL + image_config.IMAGE_GENERATION_MODEL + if image_config.IMAGE_GENERATION_MODEL else 'dall-e-2' ) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': + elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': return ( - request.app.state.config.IMAGE_GENERATION_MODEL - if request.app.state.config.IMAGE_GENERATION_MODEL + image_config.IMAGE_GENERATION_MODEL + if image_config.IMAGE_GENERATION_MODEL else 'imagen-3.0-generate-002' ) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': + elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': return ( - request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL else '' + image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else '' ) elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' - or request.app.state.config.IMAGE_GENERATION_ENGINE == '' + image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or image_config.IMAGE_GENERATION_ENGINE == '' ): try: session = await get_session() async with session.get( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', - headers={'authorization': get_automatic1111_api_auth(request)}, + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + headers={'authorization': get_automatic1111_api_auth(image_config)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: options = await r.json() @@ -159,52 +213,11 @@ class ImagesConfig(BaseModel): @router.get('/config', response_model=ImagesConfig) async def get_config(request: Request, user=Depends(get_admin_user)): - return { - 'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION, - 'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - 'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE, - 'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL, - 'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE, - 'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS, - 'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - 'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY, - 'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION, - 'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS, - 'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL, - 'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH, - 'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS, - 'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL, - 'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY, - 'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW, - 'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES, - 'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - 'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY, - 'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, - 'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT, - 'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE, - 'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL, - 'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE, - 'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, - 'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, - 'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, - 'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, - 'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, - 'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - 'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, - 'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, - } + return await get_config_values(IMAGE_CONFIG_KEYS) @router.post('/config/update') async def update_config(request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)): - request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION - - # Create Image - request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION - - request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE - await set_image_model(request, form_data.IMAGE_GENERATION_MODEL) if form_data.IMAGE_SIZE == 'auto' and not re.match( IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL ): @@ -216,100 +229,31 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends( ) pattern = r'^\d+x\d+$' - if form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE): - request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE - else: + if not (form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE)): raise HTTPException( status_code=400, detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 512x512).'), ) - if form_data.IMAGE_STEPS >= 0: - request.app.state.config.IMAGE_STEPS = form_data.IMAGE_STEPS - else: + if form_data.IMAGE_STEPS < 0: raise HTTPException( status_code=400, detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 50).'), ) - request.app.state.config.IMAGES_OPENAI_API_BASE_URL = form_data.IMAGES_OPENAI_API_BASE_URL - request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY - request.app.state.config.IMAGES_OPENAI_API_VERSION = form_data.IMAGES_OPENAI_API_VERSION - request.app.state.config.IMAGES_OPENAI_API_PARAMS = form_data.IMAGES_OPENAI_API_PARAMS - - request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL - request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH - request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS - - request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip('/') - request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY - request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW - request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES - - request.app.state.config.IMAGES_GEMINI_API_BASE_URL = form_data.IMAGES_GEMINI_API_BASE_URL - request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY - request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = form_data.IMAGES_GEMINI_ENDPOINT_METHOD - - # Edit Image - request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT - request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE - request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL - request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE - - request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = form_data.IMAGES_EDIT_OPENAI_API_BASE_URL - request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = form_data.IMAGES_EDIT_OPENAI_API_KEY - request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = form_data.IMAGES_EDIT_OPENAI_API_VERSION - - request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = form_data.IMAGES_EDIT_GEMINI_API_BASE_URL - request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = form_data.IMAGES_EDIT_GEMINI_API_KEY - - request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/') - request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = form_data.IMAGES_EDIT_COMFYUI_API_KEY - request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW - request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES - - return { - 'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION, - 'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - 'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE, - 'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL, - 'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE, - 'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS, - 'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - 'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY, - 'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION, - 'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS, - 'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL, - 'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH, - 'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS, - 'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL, - 'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY, - 'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW, - 'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES, - 'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - 'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY, - 'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, - 'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT, - 'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE, - 'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL, - 'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE, - 'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, - 'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, - 'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, - 'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, - 'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, - 'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - 'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, - 'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, - } - - -def get_automatic1111_api_auth(request: Request): - if request.app.state.config.AUTOMATIC1111_API_AUTH is None: + updates = config_updates(form_data.model_dump(), IMAGE_CONFIG_KEYS) + updates['image_generation.comfyui.base_url'] = form_data.COMFYUI_BASE_URL.strip('/') + updates['images.edit.comfyui.base_url'] = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/') + await Config.upsert(updates) + await set_image_model(request, form_data.IMAGE_GENERATION_MODEL) + return await get_config_values(IMAGE_CONFIG_KEYS) + + +def get_automatic1111_api_auth(image_config): + if image_config.AUTOMATIC1111_API_AUTH is None: return '' else: - auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode('utf-8') + auth1111_byte_string = image_config.AUTOMATIC1111_API_AUTH.encode('utf-8') auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string) auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode('utf-8') return f'Basic {auth1111_base64_encoded_string}' @@ -317,26 +261,27 @@ def get_automatic1111_api_auth(request: Request): @router.get('/config/url/verify') async def verify_url(request: Request, user=Depends(get_admin_user)): - if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111': + image_config = await get_image_config() + if image_config.IMAGE_GENERATION_ENGINE == 'automatic1111': try: session = await get_session() async with session.get( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', - headers={'authorization': get_automatic1111_api_auth(request)}, + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + headers={'authorization': get_automatic1111_api_auth(image_config)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() return True except Exception: raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': + elif image_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}'} + if image_config.COMFYUI_API_KEY: + headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'} try: session = await get_session() async with session.get( - url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', + url=f'{image_config.COMFYUI_BASE_URL}/object_info', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: @@ -350,33 +295,34 @@ async def verify_url(request: Request, user=Depends(get_admin_user)): @router.get('/models') async def get_models(request: Request, user=Depends(get_verified_user)): + image_config = await get_image_config() try: - if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': + if image_config.IMAGE_GENERATION_ENGINE == 'openai': return [ {'id': 'dall-e-2', 'name': 'DALL·E 2'}, {'id': 'dall-e-3', 'name': 'DALL·E 3'}, {'id': 'gpt-image-1', 'name': 'GPT-IMAGE 1'}, {'id': 'gpt-image-1.5', 'name': 'GPT-IMAGE 1.5'}, ] - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': + elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': return [ {'id': 'imagen-3.0-generate-002', 'name': 'imagen-3.0 generate-002'}, ] - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': + elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': # TODO - get models from comfyui - headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} + headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'} session = await get_session() async with session.get( - url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', + url=f'{image_config.COMFYUI_BASE_URL}/object_info', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: info = await r.json() - workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW) + workflow = json.loads(image_config.COMFYUI_WORKFLOW) model_node_id = None - for node in request.app.state.config.COMFYUI_WORKFLOW_NODES: + for node in image_config.COMFYUI_WORKFLOW_NODES: if node['type'] == 'model': if node['node_ids']: model_node_id = node['node_ids'][0] @@ -406,13 +352,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)): ) ) elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' - or request.app.state.config.IMAGE_GENERATION_ENGINE == '' + image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or image_config.IMAGE_GENERATION_ENGINE == '' ): session = await get_session() async with session.get( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models', - headers={'authorization': get_automatic1111_api_auth(request)}, + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models', + headers={'authorization': get_automatic1111_api_auth(image_config)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: models = await r.json() @@ -540,14 +486,15 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non @router.post('/generations') async def generate_images(request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_IMAGE_GENERATION: + image_config = await get_image_config() + if not image_config.ENABLE_IMAGE_GENERATION: raise HTTPException( status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) if user.role != 'admin' and not await has_permission( - user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS + user.id, 'features.image_generation', image_config.USER_PERMISSIONS ): raise HTTPException( status_code=403, @@ -563,13 +510,14 @@ async def image_generations( metadata: dict | None = None, user=None, ): + image_config = await get_image_config() # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an # image model other than gpt-image-1, which is warned about on settings save size = '512x512' - if request.app.state.config.IMAGE_SIZE and 'x' in request.app.state.config.IMAGE_SIZE: - size = request.app.state.config.IMAGE_SIZE + if image_config.IMAGE_SIZE and 'x' in image_config.IMAGE_SIZE: + size = image_config.IMAGE_SIZE if form_data.size and 'x' in form_data.size: size = form_data.size @@ -581,40 +529,40 @@ async def image_generations( model = await get_image_model(request) try: - if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': + if image_config.IMAGE_GENERATION_ENGINE == 'openai': headers = { - 'Authorization': f'Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}', + 'Authorization': f'Bearer {image_config.IMAGES_OPENAI_API_KEY}', 'Content-Type': 'application/json', } if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) - url = f'{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations' - if request.app.state.config.IMAGES_OPENAI_API_VERSION: - url = f'{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}' + url = f'{image_config.IMAGES_OPENAI_API_BASE_URL}/images/generations' + if image_config.IMAGES_OPENAI_API_VERSION: + url = f'{url}?api-version={image_config.IMAGES_OPENAI_API_VERSION}' data = { 'model': model, 'prompt': form_data.prompt, 'n': form_data.n, **( - {'size': form_data.size or request.app.state.config.IMAGE_SIZE} - if (form_data.size or request.app.state.config.IMAGE_SIZE) + {'size': form_data.size or image_config.IMAGE_SIZE} + if (form_data.size or image_config.IMAGE_SIZE) else {} ), **( {} if re.match( IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, - request.app.state.config.IMAGE_GENERATION_MODEL, + image_config.IMAGE_GENERATION_MODEL, ) else {'response_format': 'b64_json'} ), **( {} - if not request.app.state.config.IMAGES_OPENAI_API_PARAMS - else request.app.state.config.IMAGES_OPENAI_API_PARAMS + if not image_config.IMAGES_OPENAI_API_PARAMS + else image_config.IMAGES_OPENAI_API_PARAMS ), } @@ -643,17 +591,17 @@ async def image_generations( images.append({'url': url}) return images - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': + elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': headers = { 'Content-Type': 'application/json', - 'x-goog-api-key': request.app.state.config.IMAGES_GEMINI_API_KEY, + 'x-goog-api-key': image_config.IMAGES_GEMINI_API_KEY, } data = {} if ( - request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == '' - or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict' + image_config.IMAGES_GEMINI_ENDPOINT_METHOD == '' + or image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict' ): model = f'{model}:predict' data = { @@ -664,13 +612,13 @@ async def image_generations( }, } - elif request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent': + elif image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent': model = f'{model}:generateContent' data = {'contents': [{'parts': [{'text': form_data.prompt}]}]} session = await get_session() async with session.post( - url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}', + url=f'{image_config.IMAGES_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -701,7 +649,7 @@ async def image_generations( return images - elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': + elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': data = { 'prompt': form_data.prompt, 'width': width, @@ -709,8 +657,8 @@ async def image_generations( 'n': form_data.n, } - if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None: - data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS + if image_config.IMAGE_STEPS is not None or form_data.steps is not None: + data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS if form_data.negative_prompt is not None: data['negative_prompt'] = form_data.negative_prompt @@ -719,8 +667,8 @@ async def image_generations( **{ 'workflow': ComfyUIWorkflow( **{ - 'workflow': request.app.state.config.COMFYUI_WORKFLOW, - 'nodes': request.app.state.config.COMFYUI_WORKFLOW_NODES, + 'workflow': image_config.COMFYUI_WORKFLOW, + 'nodes': image_config.COMFYUI_WORKFLOW_NODES, } ), **data, @@ -730,8 +678,8 @@ async def image_generations( model, form_data, str(uuid.uuid4()), - request.app.state.config.COMFYUI_BASE_URL, - request.app.state.config.COMFYUI_API_KEY, + image_config.COMFYUI_BASE_URL, + image_config.COMFYUI_API_KEY, ) log.debug(f'res: {res}') @@ -739,13 +687,13 @@ async def image_generations( for image in res['data']: headers = None - if request.app.state.config.COMFYUI_API_KEY: - headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} + if image_config.COMFYUI_API_KEY: + headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'} image_data, content_type = await get_image_data( image['url'], headers, - trusted_base_url=request.app.state.config.COMFYUI_BASE_URL, + trusted_base_url=image_config.COMFYUI_BASE_URL, ) _, url = await upload_image( request, @@ -757,8 +705,8 @@ async def image_generations( images.append({'url': url}) return images elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' - or request.app.state.config.IMAGE_GENERATION_ENGINE == '' + image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or image_config.IMAGE_GENERATION_ENGINE == '' ): if form_data.model: await set_image_model(request, form_data.model) @@ -770,20 +718,20 @@ async def image_generations( 'height': height, } - if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None: - data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS + if image_config.IMAGE_STEPS is not None or form_data.steps is not None: + data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS if form_data.negative_prompt is not None: data['negative_prompt'] = form_data.negative_prompt - if request.app.state.config.AUTOMATIC1111_PARAMS: - data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS} + if image_config.AUTOMATIC1111_PARAMS: + data = {**data, **image_config.AUTOMATIC1111_PARAMS} session = await get_session() async with session.post( - url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img', + url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img', json=data, - headers={'authorization': get_automatic1111_api_auth(request)}, + headers={'authorization': get_automatic1111_api_auth(image_config)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: res = await r.json(content_type=None) @@ -826,17 +774,18 @@ async def image_edits( metadata: dict | None = None, user=Depends(get_verified_user), ): + image_config = await get_image_config() size = None width, height = None, None metadata = metadata or {} - if (request.app.state.config.IMAGE_EDIT_SIZE and 'x' in request.app.state.config.IMAGE_EDIT_SIZE) or ( + if (image_config.IMAGE_EDIT_SIZE and 'x' in image_config.IMAGE_EDIT_SIZE) or ( form_data.size and 'x' in form_data.size ): - size = form_data.size if form_data.size else request.app.state.config.IMAGE_EDIT_SIZE + size = form_data.size if form_data.size else image_config.IMAGE_EDIT_SIZE width, height = tuple(map(int, size.split('x'))) - model = request.app.state.config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model + model = image_config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model try: @@ -905,9 +854,9 @@ def get_image_file_item(base64_string, param_name='image'): ) try: - if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai': + if image_config.IMAGE_EDIT_ENGINE == 'openai': headers = { - 'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}', + 'Authorization': f'Bearer {image_config.IMAGES_EDIT_OPENAI_API_KEY}', } if ENABLE_FORWARD_USER_INFO_HEADERS: @@ -923,7 +872,7 @@ def get_image_file_item(base64_string, param_name='image'): {} if re.match( IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, - request.app.state.config.IMAGE_EDIT_MODEL, + image_config.IMAGE_EDIT_MODEL, ) else {'response_format': 'b64_json'} ), @@ -937,8 +886,8 @@ def get_image_file_item(base64_string, param_name='image'): files.append(get_image_file_item(img, 'image[]')) url_search_params = '' - if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: - url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}' + if image_config.IMAGES_EDIT_OPENAI_API_VERSION: + url_search_params += f'?api-version={image_config.IMAGES_EDIT_OPENAI_API_VERSION}' # Build multipart form data for aiohttp form = aiohttp.FormData() @@ -957,7 +906,7 @@ def get_image_file_item(base64_string, param_name='image'): session = await get_session() async with session.post( - url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}', + url=f'{image_config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}', headers=headers, data=form, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -979,10 +928,10 @@ def get_image_file_item(base64_string, param_name='image'): images.append({'url': url}) return images - elif request.app.state.config.IMAGE_EDIT_ENGINE == 'gemini': + elif image_config.IMAGE_EDIT_ENGINE == 'gemini': headers = { 'Content-Type': 'application/json', - 'x-goog-api-key': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + 'x-goog-api-key': image_config.IMAGES_EDIT_GEMINI_API_KEY, } model = f'{model}:generateContent' @@ -1012,7 +961,7 @@ def get_image_file_item(base64_string, param_name='image'): session = await get_session() async with session.post( - url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}', + url=f'{image_config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -1036,7 +985,7 @@ def get_image_file_item(base64_string, param_name='image'): return images - elif request.app.state.config.IMAGE_EDIT_ENGINE == 'comfyui': + elif image_config.IMAGE_EDIT_ENGINE == 'comfyui': try: files = [] if isinstance(form_data.image, str): @@ -1050,8 +999,8 @@ def get_image_file_item(base64_string, param_name='image'): for file_item in files: res = await comfyui_upload_image( file_item, - request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + image_config.IMAGES_EDIT_COMFYUI_BASE_URL, + image_config.IMAGES_EDIT_COMFYUI_API_KEY, ) comfyui_images.append(res.get('name', file_item[1][0])) except Exception as e: @@ -1070,8 +1019,8 @@ def get_image_file_item(base64_string, param_name='image'): **{ 'workflow': ComfyUIWorkflow( **{ - 'workflow': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - 'nodes': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + 'workflow': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW, + 'nodes': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } ), **data, @@ -1081,8 +1030,8 @@ def get_image_file_item(base64_string, param_name='image'): model, form_data, str(uuid.uuid4()), - request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + image_config.IMAGES_EDIT_COMFYUI_BASE_URL, + image_config.IMAGES_EDIT_COMFYUI_API_KEY, ) log.debug(f'res: {res}') @@ -1101,13 +1050,13 @@ def get_image_file_item(base64_string, param_name='image'): for image_url in image_urls: headers = None - if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY: - headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'} + if image_config.IMAGES_EDIT_COMFYUI_API_KEY: + headers = {'Authorization': f'Bearer {image_config.IMAGES_EDIT_COMFYUI_API_KEY}'} image_data, content_type = await get_image_data( image_url, headers, - trusted_base_url=request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL, ) _, url = await upload_image( request, diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 35069864290..3b68671c280 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -13,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files from open_webui.models.groups import Groups from open_webui.models.knowledge import ( @@ -257,7 +258,7 @@ async def create_new_knowledge( # This prevents holding a connection during embed_knowledge_base_metadata() # which makes external embedding API calls (1-5+ seconds). if user.role != 'admin' and not await has_permission( - user.id, 'workspace.knowledge', request.app.state.config.USER_PERMISSIONS + user.id, 'workspace.knowledge', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -265,7 +266,7 @@ async def create_new_knowledge( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -469,7 +470,7 @@ async def update_knowledge_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -537,7 +538,7 @@ async def update_knowledge_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 9f2623631f7..1e0e4a9ed49 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session +from open_webui.models.config import Config from open_webui.models.memories import Memories, MemoryModel from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX @@ -20,6 +21,23 @@ router = APIRouter() +async def check_memories_permission(user): + config = await Config.get_many('memories.enable', 'user.permissions') + if not config.get('memories.enable'): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if user.role != 'admin' and not await has_permission( + user.id, 'features.memories', config.get('user.permissions') + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + ############################ # GetMemories # Let what is remembered here spare someone the cost @@ -33,17 +51,7 @@ async def get_memories( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) return await Memories.get_memories_by_user_id(user.id, db=db) @@ -73,17 +81,7 @@ async def add_memory( own short-lived sessions so a connection is not held during the external embedding API call (``EMBEDDING_FUNCTION``), which can take 1-5+ seconds. """ - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) memory = await Memories.insert_new_memory(user.id, form_data.content) @@ -124,17 +122,7 @@ async def query_memory( # Database operations (get_memories_by_user_id) manage their own short-lived sessions. # This prevents holding a connection during EMBEDDING_FUNCTION() # which makes external embedding API calls (1-5+ seconds). - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) memories = await Memories.get_memories_by_user_id(user.id) if not memories: @@ -154,7 +142,7 @@ async def query_memory( # same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching # memories are surfaced (distances are normalised to 0→1, higher is # better). - relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0) + relevance_threshold = await Config.get('rag.relevance_threshold', 0.0) if results and relevance_threshold > 0.0 and results.distances and results.distances[0]: from open_webui.retrieval.vector.main import SearchResult @@ -199,17 +187,7 @@ async def reset_memory_from_vector_db( calls simultaneously. With a session held, this could block a connection for MINUTES, completely exhausting the connection pool. """ - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') @@ -250,17 +228,7 @@ async def delete_memory_by_user_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) result = await Memories.delete_memories_by_user_id(user.id, db=db) @@ -290,17 +258,7 @@ async def update_memory_by_id( # Database operations (update_memory_by_id_and_user_id) manage their own # short-lived sessions. This prevents holding a connection during # EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds). - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content) if memory is None: @@ -339,17 +297,7 @@ async def delete_memory_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if not request.app.state.config.ENABLE_MEMORIES: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - if user.role != 'admin' and not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + await check_memories_permission(user) result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 75c37b0eb9b..95b9e46bfed 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -23,6 +23,7 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.models import ( ModelAccessListResponse, @@ -230,7 +231,7 @@ async def create_new_model( ): """Create a new workspace model entry.""" if user.role != 'admin' and not await has_permission( - user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'workspace.models', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -258,7 +259,7 @@ async def create_new_model( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -289,7 +290,7 @@ async def export_models( if user.role != 'admin' and not await has_permission( user.id, 'workspace.models_export', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ): raise HTTPException( @@ -322,7 +323,7 @@ async def import_models( if user.role != 'admin' and not await has_permission( user.id, 'workspace.models_import', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ): raise HTTPException( @@ -403,7 +404,7 @@ async def import_models( # metadata-only imports. if 'access_grants' in model_data: updated_model.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, updated_model.access_grants, @@ -416,7 +417,7 @@ async def import_models( model_data['params'] = model_data.get('params', {}) new_model = ModelForm(**model_data) new_model.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, new_model.access_grants, @@ -677,7 +678,7 @@ async def update_model_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -749,7 +750,7 @@ async def update_model_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 6dccc73f6d7..8438a517c5f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -11,6 +11,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.notes import ( NoteForm, @@ -66,7 +67,7 @@ async def get_notes( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -114,7 +115,7 @@ async def get_pinned_notes( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -155,7 +156,7 @@ async def search_notes( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -208,7 +209,7 @@ async def create_new_note( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -216,7 +217,7 @@ async def create_new_note( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -249,7 +250,7 @@ async def get_note_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -308,7 +309,7 @@ async def update_note_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -332,7 +333,7 @@ async def update_note_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -375,7 +376,7 @@ async def update_note_access_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -399,7 +400,7 @@ async def update_note_access_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -427,7 +428,7 @@ async def pin_note_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -469,7 +470,7 @@ async def delete_note_by_id( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'features.notes', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index a4e166ba9ee..955ef1500b5 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -31,6 +31,7 @@ ) from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.models import Models from open_webui.models.users import UserModel @@ -181,6 +182,32 @@ def get_api_key(idx, url, configs): router = APIRouter() +OLLAMA_CONFIG_KEYS = { + 'ENABLE_OLLAMA_API': 'ollama.enable', + 'OLLAMA_BASE_URLS': 'ollama.base_urls', + 'OLLAMA_API_CONFIGS': 'ollama.api_configs', +} + + +async def get_ollama_config_values() -> dict: + values = await Config.get_many(*OLLAMA_CONFIG_KEYS.values()) + return {field: values[storage_key] for field, storage_key in OLLAMA_CONFIG_KEYS.items() if storage_key in values} + + +async def get_ollama_runtime_config() -> tuple[bool, list[str], dict]: + values = await Config.get_many('ollama.enable', 'ollama.base_urls', 'ollama.api_configs') + return ( + values.get('ollama.enable'), + values.get('ollama.base_urls') or [], + values.get('ollama.api_configs') or {}, + ) + + +async def get_ollama_connection(idx: int) -> tuple[str, dict, str | None]: + _, base_urls, api_configs = await get_ollama_runtime_config() + url = base_urls[idx] + return url, resolve_api_config(api_configs, idx, url), get_api_key(idx, url, api_configs) + @router.head('/') @router.get('/') @@ -236,11 +263,7 @@ async def get_config( user=Depends(get_admin_user), ) -> dict: """Return the current Ollama connection configuration.""" - return { - 'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API, - 'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS, - 'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS, - } + return await get_ollama_config_values() class OllamaConfigForm(BaseModel): @@ -258,20 +281,20 @@ async def update_config( user=Depends(get_admin_user), ) -> dict: """Persist updated Ollama connection settings.""" - request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API - request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS - request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS - - # Prune stale config entries that no longer map to a URL index - valid_keys = {str(i) for i in range(len(request.app.state.config.OLLAMA_BASE_URLS))} - request.app.state.config.OLLAMA_API_CONFIGS = { - k: v for k, v in request.app.state.config.OLLAMA_API_CONFIGS.items() if k in valid_keys - } - + valid_keys = {str(i) for i in range(len(form_data.OLLAMA_BASE_URLS))} + api_configs = {k: v for k, v in form_data.OLLAMA_API_CONFIGS.items() if k in valid_keys} + + await Config.upsert( + { + 'ollama.enable': form_data.ENABLE_OLLAMA_API, + 'ollama.base_urls': form_data.OLLAMA_BASE_URLS, + 'ollama.api_configs': api_configs, + } + ) return { - 'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API, - 'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS, - 'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS, + 'ENABLE_OLLAMA_API': form_data.ENABLE_OLLAMA_API, + 'OLLAMA_BASE_URLS': form_data.OLLAMA_BASE_URLS, + 'OLLAMA_API_CONFIGS': api_configs, } @@ -293,9 +316,8 @@ def merge_models_lists(model_lists) -> list[dict]: return list(merged.values()) -def _resolve_api_config(request: Request, idx: int, url: str) -> dict: +def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict: """Look up the API config for a backend by numeric index, falling back to URL key (legacy).""" - api_configs = request.app.state.config.OLLAMA_API_CONFIGS return api_configs.get(str(idx), api_configs.get(url, {})) @@ -307,15 +329,15 @@ async def get_all_models(request: Request, user: UserModel | None = None): """Aggregate model tags from every enabled Ollama backend.""" log.info('get_all_models()') - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): models_dict: dict = {'models': []} request.app.state.OLLAMA_MODELS = {} return models_dict # Fan-out tag requests to every backend tasks = [] - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): - api_config = _resolve_api_config(request, idx, url) + for idx, url in enumerate(await Config.get('ollama.base_urls', [])): + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url) if not api_config: tasks.append(send_get_request(f'{url}/api/tags', user=user)) elif api_config.get('enable', True): @@ -329,8 +351,8 @@ async def get_all_models(request: Request, user: UserModel | None = None): for idx, response in enumerate(responses): if not response: continue - url = request.app.state.config.OLLAMA_BASE_URLS[idx] - api_config = _resolve_api_config(request, idx, url) + url = (await Config.get('ollama.base_urls', []))[idx] + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url) connection_type = api_config.get('connection_type', 'local') prefix_id = api_config.get('prefix_id') @@ -394,14 +416,14 @@ async def get_ollama_tags( user=Depends(get_verified_user), ): """List Ollama model tags, optionally from a specific backend.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: result = 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) + url = (await Config.get('ollama.base_urls', []))[url_idx] + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) result = await send_request(f'{url}/api/tags', 'GET', key=key, user=user) if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: @@ -416,12 +438,12 @@ async def get_ollama_loaded_models( user=Depends(get_admin_user), ) -> dict: """List models currently loaded in Ollama memory across all backends.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): return {'models': []} tasks = [] - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): - api_config = _resolve_api_config(request, idx, url) + for idx, url in enumerate(await Config.get('ollama.base_urls', [])): + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url) if not api_config: tasks.append(send_get_request(f'{url}/api/ps', user=user)) elif api_config.get('enable', True): @@ -434,7 +456,7 @@ async def get_ollama_loaded_models( for idx, response in enumerate(responses): if not response: continue - api_config = _resolve_api_config(request.app.state.config, idx, request.app.state.config.OLLAMA_BASE_URLS[idx]) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, (await Config.get('ollama.base_urls', []))[idx]) prefix_id = api_config.get('prefix_id') if prefix_id: for m in response.get('models', []): @@ -450,19 +472,19 @@ async def get_ollama_versions( url_idx: int | None = None, ): """Return the lowest Ollama version across all configured backends.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): return {'version': False} if url_idx is not None: - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] return await send_request(f'{url}/api/version', 'GET') # Fan-out to every enabled backend tasks = [] - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + for idx, url in enumerate(await Config.get('ollama.base_urls', [])): + api_config = (await Config.get('ollama.api_configs', {})).get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + (await Config.get('ollama.api_configs', {})).get(url, {}), ) if api_config.get('enable', True): tasks.append(send_get_request(f'{url}/api/version', api_config.get('key'))) @@ -511,11 +533,11 @@ async def unload_model( results = [] errors = [] for idx in url_indices: - url = request.app.state.config.OLLAMA_BASE_URLS[idx] - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( - str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + url = (await Config.get('ollama.base_urls', []))[idx] + api_config = (await Config.get('ollama.api_configs', {})).get( + str(idx), (await Config.get('ollama.api_configs', {})).get(url, {}) ) - key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + key = get_api_key(idx, url, (await Config.get('ollama.api_configs', {}))) prefix_id = api_config.get('prefix_id', None) if prefix_id and model.startswith(f'{prefix_id}.'): @@ -552,20 +574,20 @@ async def pull_model( url_idx: int = 0, user=Depends(get_admin_user), ): - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] log.info(f'url: {url}') # Admins may pull from any registry return await send_request( f'{url}/api/pull', payload=json.dumps({**form_data, 'insecure': True}), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, ) @@ -588,7 +610,7 @@ async def push_model( user=Depends(get_admin_user), ): """Push a local model to a remote registry.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: @@ -598,13 +620,13 @@ async def push_model( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = models[form_data.model]['urls'][0] - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] log.debug(f'url: {url}') return await send_request( 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), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, ) @@ -627,16 +649,16 @@ async def create_model( url_idx: int = 0, user=Depends(get_admin_user), ): - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.debug(f'form_data: {form_data}') - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] return await send_request( 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), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, ) @@ -658,7 +680,7 @@ async def copy_model( user=Depends(get_admin_user), ): """Duplicate an existing model under a new name.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: @@ -668,8 +690,8 @@ async def copy_model( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source)) url_idx = models[form_data.source]['urls'][0] - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + url = (await Config.get('ollama.base_urls', []))[url_idx] + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) await send_request( f'{url}/api/copy', @@ -689,7 +711,7 @@ async def delete_model( user=Depends(get_admin_user), ): """Remove a model from an Ollama backend.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = form_data.model_dump(exclude_none=True) @@ -703,8 +725,8 @@ async def delete_model( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) url_idx = models[model]['urls'][0] - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + url = (await Config.get('ollama.base_urls', []))[url_idx] + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) await send_request( f'{url}/api/delete', @@ -723,7 +745,7 @@ async def show_model_info( user=Depends(get_verified_user), ): """Retrieve model metadata from the Ollama backend.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = form_data.model_dump(exclude_none=True) @@ -739,8 +761,8 @@ async def show_model_info( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) url_idx = random.choice(models[model]['urls']) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + url = (await Config.get('ollama.base_urls', []))[url_idx] + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) return await send_request( f'{url}/api/show', @@ -770,7 +792,7 @@ async def embed( user=Depends(get_verified_user), ): """Generate embeddings via the Ollama /api/embed endpoint.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_batch_embeddings {form_data}') @@ -787,12 +809,12 @@ async def embed( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + url = (await Config.get('ollama.base_urls', []))[url_idx] + api_config = (await Config.get('ollama.api_configs', {})).get( str(url_idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + (await Config.get('ollama.api_configs', {})).get(url, {}), ) - key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) prefix_id = api_config.get('prefix_id') if prefix_id: @@ -824,7 +846,7 @@ async def embeddings( user=Depends(get_verified_user), ): """Generate embeddings via the legacy Ollama /api/embeddings endpoint.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_embeddings {form_data}') @@ -841,12 +863,12 @@ async def embeddings( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + url = (await Config.get('ollama.base_urls', []))[url_idx] + api_config = (await Config.get('ollama.api_configs', {})).get( str(url_idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + (await Config.get('ollama.api_configs', {})).get(url, {}), ) - key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) prefix_id = api_config.get('prefix_id') if prefix_id: @@ -886,7 +908,7 @@ async def generate_completion( user=Depends(get_verified_user), ): """Run text completion via Ollama /api/generate.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) @@ -900,10 +922,10 @@ async def generate_completion( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + url = (await Config.get('ollama.base_urls', []))[url_idx] + api_config = (await Config.get('ollama.api_configs', {})).get( str(url_idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + (await Config.get('ollama.api_configs', {})).get(url, {}), ) prefix_id = api_config.get('prefix_id') @@ -913,7 +935,7 @@ async def generate_completion( return await send_request( f'{url}/api/generate', payload=form_data.model_dump_json(exclude_none=True).encode(), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, ) @@ -973,7 +995,7 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model), ) url_idx = random.choice(models[model].get('urls', [])) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] return url, url_idx @@ -986,7 +1008,7 @@ async def generate_chat_completion( user=Depends(get_verified_user), # noqa: B008 ): """Forward a chat completion request to an Ollama backend.""" - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) # NOTE: We intentionally do NOT use Depends(get_async_session) here. @@ -1035,7 +1057,7 @@ async def generate_chat_completion( await check_model_access(user, None, bypass_filter) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = _resolve_api_config(request, url_idx, url) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) prefix_id = api_config.get('prefix_id') if prefix_id: @@ -1044,7 +1066,7 @@ async def generate_chat_completion( return await send_request( f'{url}/api/chat', payload=json.dumps(payload), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=form_data.stream, content_type='application/x-ndjson', @@ -1121,7 +1143,7 @@ async def generate_openai_completion( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = _resolve_api_config(request, url_idx, url) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) prefix_id = api_config.get('prefix_id') if prefix_id: @@ -1130,7 +1152,7 @@ async def generate_openai_completion( return await send_request( f'{url}/v1/completions', payload=json.dumps(payload), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=payload.get('stream', False), metadata=metadata, @@ -1178,7 +1200,7 @@ async def generate_openai_chat_completion( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = _resolve_api_config(request, url_idx, url) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) prefix_id = api_config.get('prefix_id') if prefix_id: @@ -1187,7 +1209,7 @@ async def generate_openai_chat_completion( return await send_request( f'{url}/v1/chat/completions', payload=json.dumps(payload), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=payload.get('stream', False), metadata=metadata, @@ -1211,7 +1233,7 @@ async def generate_anthropic_messages( See https://docs.ollama.com/api/anthropic-compatibility """ - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = {**form_data} @@ -1227,9 +1249,9 @@ async def generate_anthropic_messages( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + api_config = (await Config.get('ollama.api_configs', {})).get( str(url_idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + (await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support ) prefix_id = api_config.get('prefix_id', None) @@ -1239,7 +1261,7 @@ async def generate_anthropic_messages( return await send_request( f'{url}/v1/messages', payload=json.dumps(payload), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=payload.get('stream', False), content_type='text/event-stream' if payload.get('stream', False) else None, @@ -1269,7 +1291,7 @@ async def generate_responses( See https://ollama.com/blog/responses-api """ - if not request.app.state.config.ENABLE_OLLAMA_API: + if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = form_data.model_dump() @@ -1285,9 +1307,9 @@ async def generate_responses( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + api_config = (await Config.get('ollama.api_configs', {})).get( str(url_idx), - request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + (await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support ) prefix_id = api_config.get('prefix_id', None) @@ -1297,7 +1319,7 @@ async def generate_responses( return await send_request( f'{url}/v1/responses', payload=json.dumps(payload), - key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=payload.get('stream', False), content_type='text/event-stream' if payload.get('stream', False) else None, @@ -1317,7 +1339,7 @@ async def get_openai_models( model_list = await get_all_models(request, user=user) raw_models = model_list['models'] else: - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + url = (await Config.get('ollama.base_urls', []))[url_idx] model_list = await send_request(f'{url}/api/tags', 'GET') raw_models = model_list.get('models', []) @@ -1429,7 +1451,7 @@ async def download_model( detail='Invalid file_url. Only URLs from allowed hosts are permitted.', ) - url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0] + url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] file_name = parse_huggingface_url(form_data.url) if not file_name: @@ -1450,7 +1472,7 @@ async def upload_model( user=Depends(get_admin_user), ): """Upload a local model file, push it as a blob, and create the model in Ollama.""" - ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0] + ollama_url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] filename = os.path.basename(file.filename) file_path = os.path.join(UPLOAD_DIR, filename) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 1977a2cb3f5..4280e4e9fd7 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -34,6 +34,7 @@ ) from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.models import Models from open_webui.models.users import UserModel @@ -236,15 +237,50 @@ def get_microsoft_entra_id_access_token(): router = APIRouter() +OPENAI_CONFIG_KEYS = { + 'ENABLE_OPENAI_API': 'openai.enable', + 'OPENAI_API_BASE_URLS': 'openai.api_base_urls', + 'OPENAI_API_KEYS': 'openai.api_keys', + 'OPENAI_API_CONFIGS': 'openai.api_configs', +} + + +async def get_openai_config() -> dict: + values = await Config.get_many(*OPENAI_CONFIG_KEYS.values()) + return {field: values[storage_key] for field, storage_key in OPENAI_CONFIG_KEYS.items() if storage_key in values} + + +async def get_openai_runtime_config() -> tuple[bool, list[str], list[str], dict]: + values = await Config.get_many('openai.enable', 'openai.api_base_urls', 'openai.api_keys', 'openai.api_configs') + return ( + values.get('openai.enable'), + values.get('openai.api_base_urls') or [], + values.get('openai.api_keys') or [], + values.get('openai.api_configs') or {}, + ) + + +async def normalize_openai_api_keys(api_base_urls: list[str], api_keys: list[str]) -> list[str]: + if len(api_keys) > len(api_base_urls): + api_keys = api_keys[: len(api_base_urls)] + elif len(api_keys) < len(api_base_urls): + api_keys = [*api_keys, *([''] * (len(api_base_urls) - len(api_keys)))] + + await Config.upsert({'openai.api_keys': api_keys}) + return api_keys + + +async def get_openai_connection(idx: int) -> tuple[str, str, dict]: + _, api_base_urls, api_keys, api_configs = await get_openai_runtime_config() + url = api_base_urls[idx] + key = api_keys[idx] + api_config = api_configs.get(str(idx), api_configs.get(url, {})) + return url, key, api_config + @router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): - return { - 'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API, - 'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS, - 'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS, - 'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS, - } + return await get_openai_config() class OpenAIConfigForm(BaseModel): @@ -256,41 +292,37 @@ class OpenAIConfigForm(BaseModel): @router.post('/config/update') async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depends(get_admin_user)): - request.app.state.config.ENABLE_OPENAI_API = form_data.ENABLE_OPENAI_API - request.app.state.config.OPENAI_API_BASE_URLS = form_data.OPENAI_API_BASE_URLS - request.app.state.config.OPENAI_API_KEYS = form_data.OPENAI_API_KEYS - - # Check if API KEYS length is same than API URLS length - if len(request.app.state.config.OPENAI_API_KEYS) != len(request.app.state.config.OPENAI_API_BASE_URLS): - if len(request.app.state.config.OPENAI_API_KEYS) > len(request.app.state.config.OPENAI_API_BASE_URLS): - request.app.state.config.OPENAI_API_KEYS = request.app.state.config.OPENAI_API_KEYS[ - : len(request.app.state.config.OPENAI_API_BASE_URLS) - ] - else: - request.app.state.config.OPENAI_API_KEYS += [''] * ( - len(request.app.state.config.OPENAI_API_BASE_URLS) - len(request.app.state.config.OPENAI_API_KEYS) - ) - - request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS - - # Remove the API configs that are not in the API URLS - keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS)))) - request.app.state.config.OPENAI_API_CONFIGS = { - key: value for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() if key in keys - } + api_keys = form_data.OPENAI_API_KEYS + + if len(api_keys) > len(form_data.OPENAI_API_BASE_URLS): + api_keys = api_keys[: len(form_data.OPENAI_API_BASE_URLS)] + elif len(api_keys) < len(form_data.OPENAI_API_BASE_URLS): + api_keys = [*api_keys, *([''] * (len(form_data.OPENAI_API_BASE_URLS) - len(api_keys)))] + + valid_keys = set(map(str, range(len(form_data.OPENAI_API_BASE_URLS)))) + api_configs = {key: value for key, value in form_data.OPENAI_API_CONFIGS.items() if key in valid_keys} + + await Config.upsert( + { + 'openai.enable': form_data.ENABLE_OPENAI_API, + 'openai.api_base_urls': form_data.OPENAI_API_BASE_URLS, + 'openai.api_keys': api_keys, + 'openai.api_configs': api_configs, + } + ) return { - 'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API, - 'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS, - 'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS, - 'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS, + 'ENABLE_OPENAI_API': form_data.ENABLE_OPENAI_API, + 'OPENAI_API_BASE_URLS': form_data.OPENAI_API_BASE_URLS, + 'OPENAI_API_KEYS': api_keys, + 'OPENAI_API_CONFIGS': api_configs, } @router.post('/audio/speech') async def speech(request: Request, user=Depends(get_verified_user)): if user.role != 'admin' and not await has_permission( - user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS + user.id, 'chat.tts', await Config.get('user.permissions') ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -299,7 +331,8 @@ async def speech(request: Request, user=Depends(get_verified_user)): idx = None try: - idx = request.app.state.config.OPENAI_API_BASE_URLS.index('https://api.openai.com/v1') + _, api_base_urls, _, _ = await get_openai_runtime_config() + idx = api_base_urls.index('https://api.openai.com/v1') body = await request.body() name = hashlib.sha256(body).hexdigest() @@ -313,12 +346,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): if file_path.is_file(): return FileResponse(file_path) - url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = request.app.state.config.OPENAI_API_KEYS[idx] - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(idx), - request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support - ) + url, key, api_config = await get_openai_connection(idx) headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) @@ -368,29 +396,15 @@ async def speech(request: Request, user=Depends(get_verified_user)): async def get_all_models_responses(request: Request, user: UserModel) -> list: - if not request.app.state.config.ENABLE_OPENAI_API: + enable_openai_api, api_base_urls, api_keys, api_configs = await get_openai_runtime_config() + if not enable_openai_api: return [] - # Cache config values locally to avoid repeated Redis lookups. - # Each access to request.app.state.config. triggers a Redis GET; - # caching here avoids hundreds of redundant round-trips. - api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS - api_keys = list(request.app.state.config.OPENAI_API_KEYS) - api_configs = request.app.state.config.OPENAI_API_CONFIGS - - # Check if API KEYS length is same than API URLS length num_urls = len(api_base_urls) num_keys = len(api_keys) if num_keys != num_urls: - # if there are more keys than urls, remove the extra keys - if num_keys > num_urls: - api_keys = api_keys[:num_urls] - request.app.state.config.OPENAI_API_KEYS = api_keys - # if there are more urls than keys, add empty keys - else: - api_keys += [''] * (num_urls - num_keys) - request.app.state.config.OPENAI_API_KEYS = api_keys + api_keys = await normalize_openai_api_keys(api_base_urls, api_keys) request_tasks = [] for idx, url in enumerate(api_base_urls): @@ -500,13 +514,10 @@ async def get_filtered_models(models, user, db=None): 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: + enable_openai_api, api_base_urls, _, _ = await get_openai_runtime_config() + if not enable_openai_api: return {'data': []} - # Cache config value locally to avoid repeated Redis lookups inside - # the nested loop in get_merged_models (one GET per model otherwise). - api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS - responses = await get_all_models_responses(request, user=user) def extract_data(response): @@ -577,7 +588,7 @@ def get_merged_models(model_lists): @router.get('/models') @router.get('/models/{url_idx}') async def get_models(request: Request, url_idx: int | None = None, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_OPENAI_API: + if not await Config.get('openai.enable'): raise HTTPException(status_code=503, detail='OpenAI API is disabled') models = { @@ -587,13 +598,7 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends( if url_idx is None: 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] - - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(url_idx), - request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support - ) + url, key, api_config = await get_openai_connection(url_idx) r = None async with aiohttp.ClientSession( @@ -1122,13 +1127,7 @@ async def generate_chat_completion( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Get the API config for the model - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(idx), - request.app.state.config.OPENAI_API_CONFIGS.get( - request.app.state.config.OPENAI_API_BASE_URLS[idx], {} - ), # Legacy support - ) + url, key, api_config = await get_openai_connection(idx) prefix_id = api_config.get('prefix_id', None) if prefix_id: @@ -1143,9 +1142,6 @@ async def generate_chat_completion( 'role': user.role, } - url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = request.app.state.config.OPENAI_API_KEYS[idx] - # Check if model is a reasoning model that needs special handling if is_openai_new_model(payload['model']): payload = openai_reasoning_model_handler(payload) @@ -1311,12 +1307,7 @@ async def embeddings(request: Request, form_data: dict, user): if model_id in models: idx = models[model_id]['urlIdx'] - url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = request.app.state.config.OPENAI_API_KEYS[idx] - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(idx), - request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support - ) + url, key, api_config = await get_openai_connection(idx) r = None streaming = False @@ -1434,12 +1425,7 @@ async def responses( if model_id in models: idx = models[model_id]['urlIdx'] - url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = request.app.state.config.OPENAI_API_KEYS[idx] - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(idx), - request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support - ) + url, key, api_config = await get_openai_connection(idx) r = None streaming = False @@ -1543,14 +1529,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): if model_id in models: idx = models[model_id]['urlIdx'] - url = request.app.state.config.OPENAI_API_BASE_URLS[idx] - key = request.app.state.config.OPENAI_API_KEYS[idx] - api_config = request.app.state.config.OPENAI_API_CONFIGS.get( - str(idx), - request.app.state.config.OPENAI_API_CONFIGS.get( - request.app.state.config.OPENAI_API_BASE_URLS[idx], {} - ), # Legacy support - ) + url, key, api_config = await get_openai_connection(idx) r = None streaming = False diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 5e0d4dc199d..7eb578bc4e7 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -18,6 +18,7 @@ from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.models.config import Config from open_webui.routers.openai import get_all_models_responses from open_webui.utils.auth import get_admin_user from pydantic import BaseModel @@ -51,6 +52,12 @@ def get_sorted_filters(model_id, models): return sorted_filters +async def get_openai_connection(url_idx: int) -> tuple[str, str]: + base_urls = await Config.get('openai.api_base_urls', []) + api_keys = await Config.get('openai.api_keys', []) + return base_urls[url_idx], api_keys[url_idx] + + async def process_pipeline_inlet_filter(request, payload, user, models): user = {'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role} model_id = payload['model'] @@ -69,8 +76,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models): except Exception: continue - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) if not key: continue @@ -133,8 +139,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models): except Exception: continue - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) if not key: continue @@ -194,11 +199,12 @@ async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): log.debug(f'get_pipelines_list: get_openai_models_responses returned {responses}') urlIdxs = [idx for idx, response in enumerate(responses) if response is not None and 'pipelines' in response] + base_urls = await Config.get('openai.api_base_urls', []) return { 'data': [ { - 'url': request.app.state.config.OPENAI_API_BASE_URLS[urlIdx], + 'url': base_urls[urlIdx], 'idx': urlIdx, } for urlIdx in urlIdxs @@ -233,8 +239,7 @@ async def upload_pipeline( with open(file_path, 'wb') as buffer: shutil.copyfileobj(file.file, buffer) - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) headers = {'Authorization': f'Bearer {key}'} @@ -294,8 +299,7 @@ async def add_pipeline(request: Request, form_data: AddPipelineForm, user=Depend try: urlIdx = form_data.urlIdx - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( @@ -338,8 +342,7 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user= try: urlIdx = form_data.urlIdx - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.delete( @@ -375,8 +378,7 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user= async def get_pipelines(request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)): response = None try: - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( @@ -416,8 +418,7 @@ async def get_pipeline_valves( ): response = None try: - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( @@ -457,8 +458,7 @@ async def get_pipeline_valves_spec( ): response = None try: - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( @@ -499,8 +499,7 @@ async def update_pipeline_valves( ): response = None try: - url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = request.app.state.config.OPENAI_API_KEYS[urlIdx] + url, key = await get_openai_connection(urlIdx) async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 1054288da08..2698bb2af6a 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -7,6 +7,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( PromptHistories, @@ -149,13 +150,13 @@ async def create_new_prompt( await has_permission( user.id, 'workspace.prompts', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ) or await has_permission( user.id, 'workspace.prompts_import', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ) ): @@ -165,7 +166,7 @@ async def create_new_prompt( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -281,7 +282,7 @@ async def update_prompt_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -438,7 +439,7 @@ async def update_prompt_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 70f6cf6309b..3269ad0e3ab 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -10,6 +10,7 @@ import uuid from datetime import datetime from pathlib import Path +from types import SimpleNamespace from typing import Callable, Iterator, Optional, Sequence, Union import tiktoken @@ -58,11 +59,13 @@ from open_webui.internal.db import get_async_db, get_async_session from open_webui.models.files import FileModel, Files, FileUpdateForm from open_webui.models.knowledge import Knowledges +from open_webui.models.config import Config # Document loaders from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.retrieval.utils import ( build_loader_from_config, + get_loader_config, filter_accessible_collections, get_content_from_url, get_embedding_function, @@ -240,6 +243,180 @@ def get_rf( router = APIRouter() +RETRIEVAL_CONFIG_KEYS = { + 'ALLOWED_FILE_EXTENSIONS': 'rag.file.allowed_extensions', + 'AZURE_AI_SEARCH_API_KEY': 'rag.web.search.azure_ai_search_api_key', + 'AZURE_AI_SEARCH_ENDPOINT': 'rag.web.search.azure_ai_search_endpoint', + 'AZURE_AI_SEARCH_INDEX_NAME': 'rag.web.search.azure_ai_search_index_name', + 'BING_SEARCH_V7_ENDPOINT': 'rag.web.search.bing_search_v7_endpoint', + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': 'rag.web.search.bing_search_v7_subscription_key', + 'BOCHA_SEARCH_API_KEY': 'rag.web.search.bocha_search_api_key', + 'BRAVE_SEARCH_API_KEY': 'rag.web.search.brave_search_api_key', + 'BRAVE_SEARCH_CONTEXT_TOKENS': 'rag.web.search.brave_search_context_tokens', + 'BYPASS_EMBEDDING_AND_RETRIEVAL': 'rag.bypass_embedding_and_retrieval', + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': 'rag.web.search.bypass_embedding_and_retrieval', + 'BYPASS_WEB_SEARCH_WEB_LOADER': 'rag.web.search.bypass_web_loader', + 'CHUNK_MIN_SIZE_TARGET': 'rag.chunk_min_size_target', + 'CHUNK_OVERLAP': 'rag.chunk_overlap', + 'CHUNK_SIZE': 'rag.chunk_size', + 'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine', + 'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config', + 'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url', + 'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key', + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': 'rag.datalab_marker_disable_image_extraction', + 'DATALAB_MARKER_FORCE_OCR': 'rag.datalab_marker_force_ocr', + 'DATALAB_MARKER_FORMAT_LINES': 'rag.datalab_marker_format_lines', + 'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format', + 'DATALAB_MARKER_PAGINATE': 'rag.datalab_marker_paginate', + 'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache', + 'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr', + 'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm', + 'DDGS_BACKEND': 'rag.web.search.ddgs_backend', + 'DOCLING_API_KEY': 'rag.docling_api_key', + 'DOCLING_PARAMS': 'rag.docling_params', + 'DOCLING_SERVER_URL': 'rag.docling_server_url', + 'DOCUMENT_INTELLIGENCE_ENDPOINT': 'rag.document_intelligence_endpoint', + 'DOCUMENT_INTELLIGENCE_KEY': 'rag.document_intelligence_key', + 'DOCUMENT_INTELLIGENCE_MODEL': 'rag.document_intelligence_model', + 'ENABLE_ASYNC_EMBEDDING': 'rag.enable_async_embedding', + 'ENABLE_GOOGLE_DRIVE_INTEGRATION': 'google_drive.enable', + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': 'rag.enable_markdown_header_text_splitter', + 'ENABLE_ONEDRIVE_INTEGRATION': 'onedrive.enable', + 'ENABLE_RAG_HYBRID_SEARCH': 'rag.enable_hybrid_search', + 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS': 'rag.enable_hybrid_search_enriched_texts', + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': 'rag.web.loader.ssl_verification', + 'ENABLE_WEB_SEARCH': 'rag.web.search.enable', + 'EXA_API_KEY': 'rag.web.search.exa_api_key', + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', + 'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url', + 'EXTERNAL_WEB_LOADER_API_KEY': 'rag.web.loader.external_web_loader_api_key', + 'EXTERNAL_WEB_LOADER_URL': 'rag.web.loader.external_web_loader_url', + 'EXTERNAL_WEB_SEARCH_API_KEY': 'rag.web.search.external_web_search_api_key', + 'EXTERNAL_WEB_SEARCH_URL': 'rag.web.search.external_web_search_url', + 'FILE_IMAGE_COMPRESSION_HEIGHT': 'file.image_compression_height', + 'FILE_IMAGE_COMPRESSION_WIDTH': 'file.image_compression_width', + 'FILE_MAX_COUNT': 'rag.file.max_count', + 'FILE_MAX_SIZE': 'rag.file.max_size', + 'FIRECRAWL_API_BASE_URL': 'rag.web.loader.firecrawl_api_url', + 'FIRECRAWL_API_KEY': 'rag.web.loader.firecrawl_api_key', + 'FIRECRAWL_TIMEOUT': 'rag.web.loader.firecrawl_timeout', + 'GOOGLE_PSE_API_KEY': 'rag.web.search.google_pse_api_key', + 'GOOGLE_PSE_ENGINE_ID': 'rag.web.search.google_pse_engine_id', + 'HYBRID_BM25_WEIGHT': 'rag.hybrid_bm25_weight', + 'JINA_API_BASE_URL': 'rag.web.search.jina_api_base_url', + 'JINA_API_KEY': 'rag.web.search.jina_api_key', + 'KAGI_SEARCH_API_KEY': 'rag.web.search.kagi_search_api_key', + 'LINKUP_API_KEY': 'rag.web.search.linkup_api_key', + 'LINKUP_SEARCH_PARAMS': 'rag.web.search.linkup_search_params', + 'MINERU_API_KEY': 'rag.mineru_api_key', + 'MINERU_API_MODE': 'rag.mineru_api_mode', + 'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout', + 'MINERU_API_URL': 'rag.mineru_api_url', + 'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions', + 'MINERU_PARAMS': 'rag.mineru_params', + 'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url', + 'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key', + 'MOJEEK_SEARCH_API_KEY': 'rag.web.search.mojeek_search_api_key', + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': 'rag.web.search.ollama_cloud_api_key', + 'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url', + 'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token', + 'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images', + 'PDF_LOADER_MODE': 'rag.pdf_loader_mode', + 'PERPLEXITY_API_KEY': 'rag.web.search.perplexity_api_key', + 'PERPLEXITY_MODEL': 'rag.web.search.perplexity_model', + 'PERPLEXITY_SEARCH_API_URL': 'rag.web.search.perplexity_search_api_url', + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': 'rag.web.search.perplexity_search_context_usage', + 'PLAYWRIGHT_TIMEOUT': 'rag.web.loader.playwright_timeout', + 'PLAYWRIGHT_WS_URL': 'rag.web.loader.playwright_ws_url', + 'RAG_AZURE_OPENAI_API_KEY': 'rag.azure_openai.api_key', + 'RAG_AZURE_OPENAI_API_VERSION': 'rag.azure_openai.api_version', + 'RAG_AZURE_OPENAI_BASE_URL': 'rag.azure_openai.base_url', + 'RAG_EMBEDDING_BATCH_SIZE': 'rag.embedding_batch_size', + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': 'rag.embedding_concurrent_requests', + 'RAG_EMBEDDING_ENGINE': 'rag.embedding_engine', + 'RAG_EMBEDDING_MODEL': 'rag.embedding_model', + 'RAG_EXTERNAL_RERANKER_API_KEY': 'rag.external_reranker_api_key', + 'RAG_EXTERNAL_RERANKER_TIMEOUT': 'rag.external_reranker_timeout', + 'RAG_EXTERNAL_RERANKER_URL': 'rag.external_reranker_url', + 'RAG_FULL_CONTEXT': 'rag.full_context', + 'RAG_OLLAMA_API_KEY': 'rag.ollama.api_key', + 'RAG_OLLAMA_BASE_URL': 'rag.ollama.base_url', + 'RAG_OPENAI_API_BASE_URL': 'rag.openai.api_base_url', + 'RAG_OPENAI_API_KEY': 'rag.openai.api_key', + 'RAG_RERANKING_BATCH_SIZE': 'rag.reranking_batch_size', + 'RAG_RERANKING_ENGINE': 'rag.reranking_engine', + 'RAG_RERANKING_MODEL': 'rag.reranking_model', + 'RAG_TEMPLATE': 'rag.template', + 'RELEVANCE_THRESHOLD': 'rag.relevance_threshold', + 'SEARCHAPI_API_KEY': 'rag.web.search.searchapi_api_key', + 'SEARCHAPI_ENGINE': 'rag.web.search.searchapi_engine', + 'SEARXNG_LANGUAGE': 'rag.web.search.searxng_language', + 'SEARXNG_QUERY_URL': 'rag.web.search.searxng_query_url', + 'SERPAPI_API_KEY': 'rag.web.search.serpapi_api_key', + 'SERPAPI_ENGINE': 'rag.web.search.serpapi_engine', + 'SERPER_API_KEY': 'rag.web.search.serper_api_key', + 'SERPLY_API_KEY': 'rag.web.search.serply_api_key', + 'SERPSTACK_API_KEY': 'rag.web.search.serpstack_api_key', + 'SERPSTACK_HTTPS': 'rag.web.search.serpstack_https', + 'SOUGOU_API_SID': 'rag.web.search.sougou_api_sid', + 'SOUGOU_API_SK': 'rag.web.search.sougou_api_sk', + 'TAVILY_API_KEY': 'rag.web.search.tavily_api_key', + 'TAVILY_EXTRACT_DEPTH': 'rag.web.search.tavily_extract_depth', + 'TEXT_SPLITTER': 'rag.text_splitter', + 'TIKA_SERVER_URL': 'rag.tika_server_url', + 'TIKTOKEN_ENCODING_NAME': 'rag.tiktoken_encoding_name', + 'TOP_K': 'rag.top_k', + 'TOP_K_RERANKER': 'rag.top_k_reranker', + 'USER_PERMISSIONS': 'user.permissions', + 'WEBUI_URL': 'webui.url', + 'WEB_FETCH_MAX_CONTENT_LENGTH': 'rag.web.fetch.max_content_length', + 'WEB_LOADER_CONCURRENT_REQUESTS': 'rag.web.loader.concurrent_requests', + 'WEB_LOADER_ENGINE': 'rag.web.loader.engine', + 'WEB_LOADER_TIMEOUT': 'rag.web.loader.timeout', + 'WEB_SEARCH_CONCURRENT_REQUESTS': 'rag.web.search.concurrent_requests', + 'WEB_SEARCH_DOMAIN_FILTER_LIST': 'rag.web.search.domain.filter_list', + 'WEB_SEARCH_ENGINE': 'rag.web.search.engine', + 'WEB_SEARCH_RESULT_COUNT': 'rag.web.search.result_count', + 'WEB_SEARCH_TRUST_ENV': 'rag.web.search.trust_env', + 'YACY_PASSWORD': 'rag.web.search.yacy_password', + 'YACY_QUERY_URL': 'rag.web.search.yacy_query_url', + 'YACY_USERNAME': 'rag.web.search.yacy_username', + 'YANDEX_WEB_SEARCH_API_KEY': 'rag.web.search.yandex_web_search_api_key', + 'YANDEX_WEB_SEARCH_CONFIG': 'rag.web.search.yandex_web_search_config', + 'YANDEX_WEB_SEARCH_URL': 'rag.web.search.yandex_web_search_url', + 'YOUCOM_API_KEY': 'rag.web.search.youcom_api_key', + 'YOUTUBE_LOADER_LANGUAGE': 'rag.youtube_loader_language', + 'YOUTUBE_LOADER_PROXY_URL': 'rag.youtube_loader_proxy_url', +} + + +class RetrievalConfig(SimpleNamespace): + def __init__(self, values: dict): + super().__init__(**values) + object.__setattr__(self, '_updates', {}) + + def __setattr__(self, key: str, value): + if key.startswith('_'): + object.__setattr__(self, key, value) + return + object.__setattr__(self, key, value) + if key in RETRIEVAL_CONFIG_KEYS: + self._updates[RETRIEVAL_CONFIG_KEYS[key]] = value + + async def save(self) -> None: + if self._updates: + await Config.upsert(dict(self._updates)) + self._updates.clear() + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +async def get_retrieval_config() -> RetrievalConfig: + return RetrievalConfig(await get_config_values(RETRIEVAL_CONFIG_KEYS)) + class CollectionNameForm(BaseModel): collection_name: str | None = None @@ -255,25 +432,26 @@ class SearchForm(BaseModel): @router.get('/embedding') async def get_embedding_config(request: Request, user=Depends(get_admin_user)): + config = await get_retrieval_config() return { 'status': True, - 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, - 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, - 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, - 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'RAG_EMBEDDING_ENGINE': config.RAG_EMBEDDING_ENGINE, + 'RAG_EMBEDDING_MODEL': config.RAG_EMBEDDING_MODEL, + 'RAG_EMBEDDING_BATCH_SIZE': config.RAG_EMBEDDING_BATCH_SIZE, + 'ENABLE_ASYNC_EMBEDDING': config.ENABLE_ASYNC_EMBEDDING, + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': config.RAG_EMBEDDING_CONCURRENT_REQUESTS, 'openai_config': { - 'url': request.app.state.config.RAG_OPENAI_API_BASE_URL, - 'key': request.app.state.config.RAG_OPENAI_API_KEY, + 'url': config.RAG_OPENAI_API_BASE_URL, + 'key': config.RAG_OPENAI_API_KEY, }, 'ollama_config': { - 'url': request.app.state.config.RAG_OLLAMA_BASE_URL, - 'key': request.app.state.config.RAG_OLLAMA_API_KEY, + 'url': config.RAG_OLLAMA_BASE_URL, + 'key': config.RAG_OLLAMA_API_KEY, }, 'azure_openai_config': { - 'url': request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, - 'key': request.app.state.config.RAG_AZURE_OPENAI_API_KEY, - 'version': request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + 'url': config.RAG_AZURE_OPENAI_BASE_URL, + 'key': config.RAG_AZURE_OPENAI_API_KEY, + 'version': config.RAG_AZURE_OPENAI_API_VERSION, }, } @@ -305,8 +483,9 @@ class EmbeddingModelUpdateForm(BaseModel): RAG_EMBEDDING_CONCURRENT_REQUESTS: int | None = 0 -def unload_embedding_model(request: Request): - if request.app.state.config.RAG_EMBEDDING_ENGINE == '': +async def unload_embedding_model(request: Request): + config = await get_retrieval_config() + if config.RAG_EMBEDDING_ENGINE == '': # unloads current internal embedding model and clears VRAM cache request.app.state.ef = None request.app.state.EMBEDDING_FUNCTION = None @@ -322,91 +501,93 @@ def unload_embedding_model(request: Request): @router.post('/embedding/update') async def update_embedding_config(request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)): + config = await get_retrieval_config() log.info( - f'Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}' + f'Updating embedding model: {config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}' ) - unload_embedding_model(request) + await unload_embedding_model(request) try: - request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE - request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL.strip() - request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE - request.app.state.config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING - request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS + config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE + config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL.strip() + config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE + config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING + config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS - if request.app.state.config.RAG_EMBEDDING_ENGINE in [ + if config.RAG_EMBEDDING_ENGINE in [ 'ollama', 'openai', 'azure_openai', ]: if form_data.openai_config is not None: - request.app.state.config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url - request.app.state.config.RAG_OPENAI_API_KEY = form_data.openai_config.key + config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url + config.RAG_OPENAI_API_KEY = form_data.openai_config.key if form_data.ollama_config is not None: - request.app.state.config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url - request.app.state.config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key + config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url + config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key if form_data.azure_openai_config is not None: - request.app.state.config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url - request.app.state.config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key - request.app.state.config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version + config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url + config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key + config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version request.app.state.ef = get_ef( - request.app.state.config.RAG_EMBEDDING_ENGINE, - request.app.state.config.RAG_EMBEDDING_MODEL, + config.RAG_EMBEDDING_ENGINE, + config.RAG_EMBEDDING_MODEL, ) request.app.state.EMBEDDING_FUNCTION = get_embedding_function( - request.app.state.config.RAG_EMBEDDING_ENGINE, - request.app.state.config.RAG_EMBEDDING_MODEL, + config.RAG_EMBEDDING_ENGINE, + config.RAG_EMBEDDING_MODEL, request.app.state.ef, ( - request.app.state.config.RAG_OPENAI_API_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' + config.RAG_OPENAI_API_BASE_URL + if config.RAG_EMBEDDING_ENGINE == 'openai' else ( - request.app.state.config.RAG_OLLAMA_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL + config.RAG_OLLAMA_BASE_URL + if config.RAG_EMBEDDING_ENGINE == 'ollama' + else config.RAG_AZURE_OPENAI_BASE_URL ) ), ( - request.app.state.config.RAG_OPENAI_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' + config.RAG_OPENAI_API_KEY + if config.RAG_EMBEDDING_ENGINE == 'openai' else ( - request.app.state.config.RAG_OLLAMA_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else request.app.state.config.RAG_AZURE_OPENAI_API_KEY + config.RAG_OLLAMA_API_KEY + if config.RAG_EMBEDDING_ENGINE == 'ollama' + else config.RAG_AZURE_OPENAI_API_KEY ) ), - request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + config.RAG_EMBEDDING_BATCH_SIZE, azure_api_version=( - request.app.state.config.RAG_AZURE_OPENAI_API_VERSION - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' + config.RAG_AZURE_OPENAI_API_VERSION + if config.RAG_EMBEDDING_ENGINE == 'azure_openai' else None ), - enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, - concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + enable_async=config.ENABLE_ASYNC_EMBEDDING, + concurrent_requests=config.RAG_EMBEDDING_CONCURRENT_REQUESTS, ) + await config.save() return { 'status': True, - 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, - 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, - 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, - 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'RAG_EMBEDDING_ENGINE': config.RAG_EMBEDDING_ENGINE, + 'RAG_EMBEDDING_MODEL': config.RAG_EMBEDDING_MODEL, + 'RAG_EMBEDDING_BATCH_SIZE': config.RAG_EMBEDDING_BATCH_SIZE, + 'ENABLE_ASYNC_EMBEDDING': config.ENABLE_ASYNC_EMBEDDING, + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': config.RAG_EMBEDDING_CONCURRENT_REQUESTS, 'openai_config': { - 'url': request.app.state.config.RAG_OPENAI_API_BASE_URL, - 'key': request.app.state.config.RAG_OPENAI_API_KEY, + 'url': config.RAG_OPENAI_API_BASE_URL, + 'key': config.RAG_OPENAI_API_KEY, }, 'ollama_config': { - 'url': request.app.state.config.RAG_OLLAMA_BASE_URL, - 'key': request.app.state.config.RAG_OLLAMA_API_KEY, + 'url': config.RAG_OLLAMA_BASE_URL, + 'key': config.RAG_OLLAMA_API_KEY, }, 'azure_openai_config': { - 'url': request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, - 'key': request.app.state.config.RAG_AZURE_OPENAI_API_KEY, - 'version': request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + 'url': config.RAG_AZURE_OPENAI_BASE_URL, + 'key': config.RAG_AZURE_OPENAI_API_KEY, + 'version': config.RAG_AZURE_OPENAI_API_VERSION, }, } except Exception as e: @@ -419,144 +600,146 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd @router.get('/config') async def get_rag_config(request: Request, user=Depends(get_admin_user)): + config = await get_retrieval_config() + await config.save() return { 'status': True, # RAG settings - 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, - 'TOP_K': request.app.state.config.TOP_K, - 'BYPASS_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, - 'RAG_FULL_CONTEXT': request.app.state.config.RAG_FULL_CONTEXT, + 'RAG_TEMPLATE': config.RAG_TEMPLATE, + 'TOP_K': config.TOP_K, + 'BYPASS_EMBEDDING_AND_RETRIEVAL': config.BYPASS_EMBEDDING_AND_RETRIEVAL, + 'RAG_FULL_CONTEXT': config.RAG_FULL_CONTEXT, # Hybrid search settings - 'ENABLE_RAG_HYBRID_SEARCH': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, - 'TOP_K_RERANKER': request.app.state.config.TOP_K_RERANKER, - 'RELEVANCE_THRESHOLD': request.app.state.config.RELEVANCE_THRESHOLD, - 'HYBRID_BM25_WEIGHT': request.app.state.config.HYBRID_BM25_WEIGHT, + 'ENABLE_RAG_HYBRID_SEARCH': config.ENABLE_RAG_HYBRID_SEARCH, + 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS': config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + 'TOP_K_RERANKER': config.TOP_K_RERANKER, + 'RELEVANCE_THRESHOLD': config.RELEVANCE_THRESHOLD, + 'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT, # Content extraction settings - 'CONTENT_EXTRACTION_ENGINE': request.app.state.config.CONTENT_EXTRACTION_ENGINE, - 'PDF_EXTRACT_IMAGES': request.app.state.config.PDF_EXTRACT_IMAGES, - 'PDF_LOADER_MODE': request.app.state.config.PDF_LOADER_MODE, - 'DATALAB_MARKER_API_KEY': request.app.state.config.DATALAB_MARKER_API_KEY, - 'DATALAB_MARKER_API_BASE_URL': request.app.state.config.DATALAB_MARKER_API_BASE_URL, - 'DATALAB_MARKER_ADDITIONAL_CONFIG': request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, - 'DATALAB_MARKER_SKIP_CACHE': request.app.state.config.DATALAB_MARKER_SKIP_CACHE, - 'DATALAB_MARKER_FORCE_OCR': request.app.state.config.DATALAB_MARKER_FORCE_OCR, - 'DATALAB_MARKER_PAGINATE': request.app.state.config.DATALAB_MARKER_PAGINATE, - 'DATALAB_MARKER_STRIP_EXISTING_OCR': request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, - 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - 'DATALAB_MARKER_FORMAT_LINES': request.app.state.config.DATALAB_MARKER_FORMAT_LINES, - 'DATALAB_MARKER_USE_LLM': request.app.state.config.DATALAB_MARKER_USE_LLM, - 'DATALAB_MARKER_OUTPUT_FORMAT': request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, - 'EXTERNAL_DOCUMENT_LOADER_URL': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, - 'EXTERNAL_DOCUMENT_LOADER_API_KEY': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - 'TIKA_SERVER_URL': request.app.state.config.TIKA_SERVER_URL, - 'DOCLING_SERVER_URL': request.app.state.config.DOCLING_SERVER_URL, - 'DOCLING_API_KEY': request.app.state.config.DOCLING_API_KEY, - 'DOCLING_PARAMS': request.app.state.config.DOCLING_PARAMS, - 'DOCUMENT_INTELLIGENCE_ENDPOINT': request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, - 'DOCUMENT_INTELLIGENCE_KEY': request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, - 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, - 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, - 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, - 'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL, - 'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN, + 'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE, + 'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES, + 'PDF_LOADER_MODE': config.PDF_LOADER_MODE, + 'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY, + 'DATALAB_MARKER_API_BASE_URL': config.DATALAB_MARKER_API_BASE_URL, + 'DATALAB_MARKER_ADDITIONAL_CONFIG': config.DATALAB_MARKER_ADDITIONAL_CONFIG, + 'DATALAB_MARKER_SKIP_CACHE': config.DATALAB_MARKER_SKIP_CACHE, + 'DATALAB_MARKER_FORCE_OCR': config.DATALAB_MARKER_FORCE_OCR, + 'DATALAB_MARKER_PAGINATE': config.DATALAB_MARKER_PAGINATE, + 'DATALAB_MARKER_STRIP_EXISTING_OCR': config.DATALAB_MARKER_STRIP_EXISTING_OCR, + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + 'DATALAB_MARKER_FORMAT_LINES': config.DATALAB_MARKER_FORMAT_LINES, + 'DATALAB_MARKER_USE_LLM': config.DATALAB_MARKER_USE_LLM, + 'DATALAB_MARKER_OUTPUT_FORMAT': config.DATALAB_MARKER_OUTPUT_FORMAT, + 'EXTERNAL_DOCUMENT_LOADER_URL': config.EXTERNAL_DOCUMENT_LOADER_URL, + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, + 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, + 'DOCLING_API_KEY': config.DOCLING_API_KEY, + 'DOCLING_PARAMS': config.DOCLING_PARAMS, + 'DOCUMENT_INTELLIGENCE_ENDPOINT': config.DOCUMENT_INTELLIGENCE_ENDPOINT, + 'DOCUMENT_INTELLIGENCE_KEY': config.DOCUMENT_INTELLIGENCE_KEY, + 'DOCUMENT_INTELLIGENCE_MODEL': config.DOCUMENT_INTELLIGENCE_MODEL, + 'MISTRAL_OCR_API_BASE_URL': config.MISTRAL_OCR_API_BASE_URL, + 'MISTRAL_OCR_API_KEY': config.MISTRAL_OCR_API_KEY, + 'PADDLEOCR_VL_BASE_URL': config.PADDLEOCR_VL_BASE_URL, + 'PADDLEOCR_VL_TOKEN': config.PADDLEOCR_VL_TOKEN, # MinerU settings - 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, - 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, - 'MINERU_API_KEY': request.app.state.config.MINERU_API_KEY, - 'MINERU_API_TIMEOUT': request.app.state.config.MINERU_API_TIMEOUT, - 'MINERU_PARAMS': request.app.state.config.MINERU_PARAMS, - 'MINERU_FILE_EXTENSIONS': request.app.state.config.MINERU_FILE_EXTENSIONS, + 'MINERU_API_MODE': config.MINERU_API_MODE, + 'MINERU_API_URL': config.MINERU_API_URL, + 'MINERU_API_KEY': config.MINERU_API_KEY, + 'MINERU_API_TIMEOUT': config.MINERU_API_TIMEOUT, + 'MINERU_PARAMS': config.MINERU_PARAMS, + 'MINERU_FILE_EXTENSIONS': config.MINERU_FILE_EXTENSIONS, # Reranking settings - 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, - 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, - 'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE, - 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, - 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + 'RAG_RERANKING_MODEL': config.RAG_RERANKING_MODEL, + 'RAG_RERANKING_ENGINE': config.RAG_RERANKING_ENGINE, + 'RAG_RERANKING_BATCH_SIZE': config.RAG_RERANKING_BATCH_SIZE, + 'RAG_EXTERNAL_RERANKER_URL': config.RAG_EXTERNAL_RERANKER_URL, + 'RAG_EXTERNAL_RERANKER_API_KEY': config.RAG_EXTERNAL_RERANKER_API_KEY, + 'RAG_EXTERNAL_RERANKER_TIMEOUT': config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings - 'TEXT_SPLITTER': request.app.state.config.TEXT_SPLITTER, - 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, - 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, - 'CHUNK_MIN_SIZE_TARGET': request.app.state.config.CHUNK_MIN_SIZE_TARGET, - 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, + 'TEXT_SPLITTER': config.TEXT_SPLITTER, + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, + 'CHUNK_SIZE': config.CHUNK_SIZE, + 'CHUNK_MIN_SIZE_TARGET': config.CHUNK_MIN_SIZE_TARGET, + 'CHUNK_OVERLAP': config.CHUNK_OVERLAP, # File upload settings - 'FILE_MAX_SIZE': request.app.state.config.FILE_MAX_SIZE, - 'FILE_MAX_COUNT': request.app.state.config.FILE_MAX_COUNT, - 'FILE_IMAGE_COMPRESSION_WIDTH': request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - 'FILE_IMAGE_COMPRESSION_HEIGHT': request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, - 'ALLOWED_FILE_EXTENSIONS': request.app.state.config.ALLOWED_FILE_EXTENSIONS, + 'FILE_MAX_SIZE': config.FILE_MAX_SIZE, + 'FILE_MAX_COUNT': config.FILE_MAX_COUNT, + 'FILE_IMAGE_COMPRESSION_WIDTH': config.FILE_IMAGE_COMPRESSION_WIDTH, + 'FILE_IMAGE_COMPRESSION_HEIGHT': config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'ALLOWED_FILE_EXTENSIONS': config.ALLOWED_FILE_EXTENSIONS, # Integration settings - 'ENABLE_GOOGLE_DRIVE_INTEGRATION': request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - 'ENABLE_ONEDRIVE_INTEGRATION': request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + 'ENABLE_GOOGLE_DRIVE_INTEGRATION': config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'ENABLE_ONEDRIVE_INTEGRATION': config.ENABLE_ONEDRIVE_INTEGRATION, # Web search settings 'web': { - 'ENABLE_WEB_SEARCH': request.app.state.config.ENABLE_WEB_SEARCH, - 'WEB_SEARCH_ENGINE': request.app.state.config.WEB_SEARCH_ENGINE, - 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, - 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, - 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - 'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH, - 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - 'BYPASS_WEB_SEARCH_WEB_LOADER': request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, - 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, - 'SEARXNG_QUERY_URL': request.app.state.config.SEARXNG_QUERY_URL, - 'SEARXNG_LANGUAGE': request.app.state.config.SEARXNG_LANGUAGE, - 'YACY_QUERY_URL': request.app.state.config.YACY_QUERY_URL, - 'YACY_USERNAME': request.app.state.config.YACY_USERNAME, - 'YACY_PASSWORD': request.app.state.config.YACY_PASSWORD, - 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, - 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, - 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, - 'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, - 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, - 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, - 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, - 'SERPSTACK_API_KEY': request.app.state.config.SERPSTACK_API_KEY, - 'SERPSTACK_HTTPS': request.app.state.config.SERPSTACK_HTTPS, - 'SERPER_API_KEY': request.app.state.config.SERPER_API_KEY, - 'SERPLY_API_KEY': request.app.state.config.SERPLY_API_KEY, - 'DDGS_BACKEND': request.app.state.config.DDGS_BACKEND, - 'TAVILY_API_KEY': request.app.state.config.TAVILY_API_KEY, - 'SEARCHAPI_API_KEY': request.app.state.config.SEARCHAPI_API_KEY, - 'SEARCHAPI_ENGINE': request.app.state.config.SEARCHAPI_ENGINE, - 'SERPAPI_API_KEY': request.app.state.config.SERPAPI_API_KEY, - 'SERPAPI_ENGINE': request.app.state.config.SERPAPI_ENGINE, - 'JINA_API_KEY': request.app.state.config.JINA_API_KEY, - 'JINA_API_BASE_URL': request.app.state.config.JINA_API_BASE_URL, - '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, - 'PERPLEXITY_MODEL': request.app.state.config.PERPLEXITY_MODEL, - 'PERPLEXITY_SEARCH_CONTEXT_USAGE': request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, - 'PERPLEXITY_SEARCH_API_URL': request.app.state.config.PERPLEXITY_SEARCH_API_URL, - 'SOUGOU_API_SID': request.app.state.config.SOUGOU_API_SID, - 'SOUGOU_API_SK': request.app.state.config.SOUGOU_API_SK, - 'WEB_LOADER_ENGINE': request.app.state.config.WEB_LOADER_ENGINE, - 'WEB_LOADER_TIMEOUT': request.app.state.config.WEB_LOADER_TIMEOUT, - 'ENABLE_WEB_LOADER_SSL_VERIFICATION': request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - 'PLAYWRIGHT_WS_URL': request.app.state.config.PLAYWRIGHT_WS_URL, - 'PLAYWRIGHT_TIMEOUT': request.app.state.config.PLAYWRIGHT_TIMEOUT, - 'FIRECRAWL_API_KEY': request.app.state.config.FIRECRAWL_API_KEY, - 'FIRECRAWL_API_BASE_URL': request.app.state.config.FIRECRAWL_API_BASE_URL, - 'FIRECRAWL_TIMEOUT': request.app.state.config.FIRECRAWL_TIMEOUT, - 'TAVILY_EXTRACT_DEPTH': request.app.state.config.TAVILY_EXTRACT_DEPTH, - 'EXTERNAL_WEB_SEARCH_URL': request.app.state.config.EXTERNAL_WEB_SEARCH_URL, - 'EXTERNAL_WEB_SEARCH_API_KEY': request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, - 'EXTERNAL_WEB_LOADER_URL': request.app.state.config.EXTERNAL_WEB_LOADER_URL, - 'EXTERNAL_WEB_LOADER_API_KEY': request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, - 'YOUTUBE_LOADER_LANGUAGE': request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - 'YOUTUBE_LOADER_PROXY_URL': request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + 'ENABLE_WEB_SEARCH': config.ENABLE_WEB_SEARCH, + 'WEB_SEARCH_ENGINE': config.WEB_SEARCH_ENGINE, + 'WEB_SEARCH_TRUST_ENV': config.WEB_SEARCH_TRUST_ENV, + 'WEB_SEARCH_RESULT_COUNT': config.WEB_SEARCH_RESULT_COUNT, + 'WEB_SEARCH_CONCURRENT_REQUESTS': config.WEB_SEARCH_CONCURRENT_REQUESTS, + 'WEB_FETCH_MAX_CONTENT_LENGTH': config.WEB_FETCH_MAX_CONTENT_LENGTH, + 'WEB_LOADER_CONCURRENT_REQUESTS': config.WEB_LOADER_CONCURRENT_REQUESTS, + 'WEB_SEARCH_DOMAIN_FILTER_LIST': config.WEB_SEARCH_DOMAIN_FILTER_LIST, + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'BYPASS_WEB_SEARCH_WEB_LOADER': config.BYPASS_WEB_SEARCH_WEB_LOADER, + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL, + 'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE, + 'YACY_QUERY_URL': config.YACY_QUERY_URL, + 'YACY_USERNAME': config.YACY_USERNAME, + 'YACY_PASSWORD': config.YACY_PASSWORD, + 'GOOGLE_PSE_API_KEY': config.GOOGLE_PSE_API_KEY, + 'GOOGLE_PSE_ENGINE_ID': config.GOOGLE_PSE_ENGINE_ID, + 'BRAVE_SEARCH_API_KEY': config.BRAVE_SEARCH_API_KEY, + 'BRAVE_SEARCH_CONTEXT_TOKENS': config.BRAVE_SEARCH_CONTEXT_TOKENS, + 'KAGI_SEARCH_API_KEY': config.KAGI_SEARCH_API_KEY, + 'MOJEEK_SEARCH_API_KEY': config.MOJEEK_SEARCH_API_KEY, + 'BOCHA_SEARCH_API_KEY': config.BOCHA_SEARCH_API_KEY, + 'SERPSTACK_API_KEY': config.SERPSTACK_API_KEY, + 'SERPSTACK_HTTPS': config.SERPSTACK_HTTPS, + 'SERPER_API_KEY': config.SERPER_API_KEY, + 'SERPLY_API_KEY': config.SERPLY_API_KEY, + 'DDGS_BACKEND': config.DDGS_BACKEND, + 'TAVILY_API_KEY': config.TAVILY_API_KEY, + 'SEARCHAPI_API_KEY': config.SEARCHAPI_API_KEY, + 'SEARCHAPI_ENGINE': config.SEARCHAPI_ENGINE, + 'SERPAPI_API_KEY': config.SERPAPI_API_KEY, + 'SERPAPI_ENGINE': config.SERPAPI_ENGINE, + 'JINA_API_KEY': config.JINA_API_KEY, + 'JINA_API_BASE_URL': config.JINA_API_BASE_URL, + 'BING_SEARCH_V7_ENDPOINT': config.BING_SEARCH_V7_ENDPOINT, + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'EXA_API_KEY': config.EXA_API_KEY, + 'PERPLEXITY_API_KEY': config.PERPLEXITY_API_KEY, + 'PERPLEXITY_MODEL': config.PERPLEXITY_MODEL, + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': config.PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'PERPLEXITY_SEARCH_API_URL': config.PERPLEXITY_SEARCH_API_URL, + 'SOUGOU_API_SID': config.SOUGOU_API_SID, + 'SOUGOU_API_SK': config.SOUGOU_API_SK, + 'WEB_LOADER_ENGINE': config.WEB_LOADER_ENGINE, + 'WEB_LOADER_TIMEOUT': config.WEB_LOADER_TIMEOUT, + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'PLAYWRIGHT_WS_URL': config.PLAYWRIGHT_WS_URL, + 'PLAYWRIGHT_TIMEOUT': config.PLAYWRIGHT_TIMEOUT, + 'FIRECRAWL_API_KEY': config.FIRECRAWL_API_KEY, + 'FIRECRAWL_API_BASE_URL': config.FIRECRAWL_API_BASE_URL, + 'FIRECRAWL_TIMEOUT': config.FIRECRAWL_TIMEOUT, + 'TAVILY_EXTRACT_DEPTH': config.TAVILY_EXTRACT_DEPTH, + 'EXTERNAL_WEB_SEARCH_URL': config.EXTERNAL_WEB_SEARCH_URL, + 'EXTERNAL_WEB_SEARCH_API_KEY': config.EXTERNAL_WEB_SEARCH_API_KEY, + 'EXTERNAL_WEB_LOADER_URL': config.EXTERNAL_WEB_LOADER_URL, + 'EXTERNAL_WEB_LOADER_API_KEY': config.EXTERNAL_WEB_LOADER_API_KEY, + 'YOUTUBE_LOADER_LANGUAGE': config.YOUTUBE_LOADER_LANGUAGE, + 'YOUTUBE_LOADER_PROXY_URL': config.YOUTUBE_LOADER_PROXY_URL, 'YOUTUBE_LOADER_TRANSLATION': request.app.state.YOUTUBE_LOADER_TRANSLATION, - 'YANDEX_WEB_SEARCH_URL': request.app.state.config.YANDEX_WEB_SEARCH_URL, - 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, - 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, - 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, - 'LINKUP_API_KEY': request.app.state.config.LINKUP_API_KEY, - 'LINKUP_SEARCH_PARAMS': request.app.state.config.LINKUP_SEARCH_PARAMS, + 'YANDEX_WEB_SEARCH_URL': config.YANDEX_WEB_SEARCH_URL, + 'YANDEX_WEB_SEARCH_API_KEY': config.YANDEX_WEB_SEARCH_API_KEY, + 'YANDEX_WEB_SEARCH_CONFIG': config.YANDEX_WEB_SEARCH_CONFIG, + 'YOUCOM_API_KEY': config.YOUCOM_API_KEY, + 'LINKUP_API_KEY': config.LINKUP_API_KEY, + 'LINKUP_SEARCH_PARAMS': config.LINKUP_SEARCH_PARAMS, }, } @@ -717,203 +900,204 @@ class ConfigForm(BaseModel): @router.post('/config/update') async def update_rag_config(request: Request, form_data: ConfigForm, user=Depends(get_admin_user)): # RAG settings - request.app.state.config.RAG_TEMPLATE = ( - form_data.RAG_TEMPLATE if form_data.RAG_TEMPLATE is not None else request.app.state.config.RAG_TEMPLATE + config = await get_retrieval_config() + config.RAG_TEMPLATE = ( + form_data.RAG_TEMPLATE if form_data.RAG_TEMPLATE is not None else config.RAG_TEMPLATE ) - request.app.state.config.TOP_K = form_data.TOP_K if form_data.TOP_K is not None else request.app.state.config.TOP_K - request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = ( + config.TOP_K = form_data.TOP_K if form_data.TOP_K is not None else config.TOP_K + 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 + else config.BYPASS_EMBEDDING_AND_RETRIEVAL ) - request.app.state.config.RAG_FULL_CONTEXT = ( + config.RAG_FULL_CONTEXT = ( form_data.RAG_FULL_CONTEXT if form_data.RAG_FULL_CONTEXT is not None - else request.app.state.config.RAG_FULL_CONTEXT + else config.RAG_FULL_CONTEXT ) # Hybrid search settings - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( + config.ENABLE_RAG_HYBRID_SEARCH = ( form_data.ENABLE_RAG_HYBRID_SEARCH if form_data.ENABLE_RAG_HYBRID_SEARCH is not None - else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH + else config.ENABLE_RAG_HYBRID_SEARCH ) - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = ( + config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = ( form_data.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS if form_data.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS is not None - else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS + else config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS ) - request.app.state.config.TOP_K_RERANKER = ( - form_data.TOP_K_RERANKER if form_data.TOP_K_RERANKER is not None else request.app.state.config.TOP_K_RERANKER + config.TOP_K_RERANKER = ( + form_data.TOP_K_RERANKER if form_data.TOP_K_RERANKER is not None else config.TOP_K_RERANKER ) - request.app.state.config.RELEVANCE_THRESHOLD = ( + config.RELEVANCE_THRESHOLD = ( form_data.RELEVANCE_THRESHOLD if form_data.RELEVANCE_THRESHOLD is not None - else request.app.state.config.RELEVANCE_THRESHOLD + else config.RELEVANCE_THRESHOLD ) - request.app.state.config.HYBRID_BM25_WEIGHT = ( + config.HYBRID_BM25_WEIGHT = ( form_data.HYBRID_BM25_WEIGHT if form_data.HYBRID_BM25_WEIGHT is not None - else request.app.state.config.HYBRID_BM25_WEIGHT + else config.HYBRID_BM25_WEIGHT ) # Content extraction settings - request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( + config.CONTENT_EXTRACTION_ENGINE = ( form_data.CONTENT_EXTRACTION_ENGINE if form_data.CONTENT_EXTRACTION_ENGINE is not None - else request.app.state.config.CONTENT_EXTRACTION_ENGINE + else config.CONTENT_EXTRACTION_ENGINE ) - request.app.state.config.PDF_EXTRACT_IMAGES = ( + config.PDF_EXTRACT_IMAGES = ( form_data.PDF_EXTRACT_IMAGES if form_data.PDF_EXTRACT_IMAGES is not None - else request.app.state.config.PDF_EXTRACT_IMAGES + else config.PDF_EXTRACT_IMAGES ) - request.app.state.config.PDF_LOADER_MODE = ( - form_data.PDF_LOADER_MODE if form_data.PDF_LOADER_MODE is not None else request.app.state.config.PDF_LOADER_MODE + config.PDF_LOADER_MODE = ( + form_data.PDF_LOADER_MODE if form_data.PDF_LOADER_MODE is not None else config.PDF_LOADER_MODE ) - request.app.state.config.DATALAB_MARKER_API_KEY = ( + config.DATALAB_MARKER_API_KEY = ( form_data.DATALAB_MARKER_API_KEY if form_data.DATALAB_MARKER_API_KEY is not None - else request.app.state.config.DATALAB_MARKER_API_KEY + else config.DATALAB_MARKER_API_KEY ) - request.app.state.config.DATALAB_MARKER_API_BASE_URL = ( + config.DATALAB_MARKER_API_BASE_URL = ( form_data.DATALAB_MARKER_API_BASE_URL if form_data.DATALAB_MARKER_API_BASE_URL is not None - else request.app.state.config.DATALAB_MARKER_API_BASE_URL + else config.DATALAB_MARKER_API_BASE_URL ) - request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = ( + config.DATALAB_MARKER_ADDITIONAL_CONFIG = ( form_data.DATALAB_MARKER_ADDITIONAL_CONFIG if form_data.DATALAB_MARKER_ADDITIONAL_CONFIG is not None - else request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG + else config.DATALAB_MARKER_ADDITIONAL_CONFIG ) - request.app.state.config.DATALAB_MARKER_SKIP_CACHE = ( + config.DATALAB_MARKER_SKIP_CACHE = ( form_data.DATALAB_MARKER_SKIP_CACHE if form_data.DATALAB_MARKER_SKIP_CACHE is not None - else request.app.state.config.DATALAB_MARKER_SKIP_CACHE + else config.DATALAB_MARKER_SKIP_CACHE ) - request.app.state.config.DATALAB_MARKER_FORCE_OCR = ( + config.DATALAB_MARKER_FORCE_OCR = ( form_data.DATALAB_MARKER_FORCE_OCR if form_data.DATALAB_MARKER_FORCE_OCR is not None - else request.app.state.config.DATALAB_MARKER_FORCE_OCR + else config.DATALAB_MARKER_FORCE_OCR ) - request.app.state.config.DATALAB_MARKER_PAGINATE = ( + config.DATALAB_MARKER_PAGINATE = ( form_data.DATALAB_MARKER_PAGINATE if form_data.DATALAB_MARKER_PAGINATE is not None - else request.app.state.config.DATALAB_MARKER_PAGINATE + else config.DATALAB_MARKER_PAGINATE ) - request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = ( + config.DATALAB_MARKER_STRIP_EXISTING_OCR = ( form_data.DATALAB_MARKER_STRIP_EXISTING_OCR if form_data.DATALAB_MARKER_STRIP_EXISTING_OCR is not None - else request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR + else config.DATALAB_MARKER_STRIP_EXISTING_OCR ) - request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( + config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None - else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION + else config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION ) - request.app.state.config.DATALAB_MARKER_FORMAT_LINES = ( + config.DATALAB_MARKER_FORMAT_LINES = ( form_data.DATALAB_MARKER_FORMAT_LINES if form_data.DATALAB_MARKER_FORMAT_LINES is not None - else request.app.state.config.DATALAB_MARKER_FORMAT_LINES + else config.DATALAB_MARKER_FORMAT_LINES ) - request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = ( + config.DATALAB_MARKER_OUTPUT_FORMAT = ( form_data.DATALAB_MARKER_OUTPUT_FORMAT if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None - else request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT + else config.DATALAB_MARKER_OUTPUT_FORMAT ) - request.app.state.config.DATALAB_MARKER_USE_LLM = ( + config.DATALAB_MARKER_USE_LLM = ( form_data.DATALAB_MARKER_USE_LLM if form_data.DATALAB_MARKER_USE_LLM is not None - else request.app.state.config.DATALAB_MARKER_USE_LLM + else config.DATALAB_MARKER_USE_LLM ) - request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = ( + config.EXTERNAL_DOCUMENT_LOADER_URL = ( form_data.EXTERNAL_DOCUMENT_LOADER_URL if form_data.EXTERNAL_DOCUMENT_LOADER_URL is not None - else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL + else config.EXTERNAL_DOCUMENT_LOADER_URL ) - request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = ( + config.EXTERNAL_DOCUMENT_LOADER_API_KEY = ( form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY if form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY is not None - else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY + else config.EXTERNAL_DOCUMENT_LOADER_API_KEY ) - request.app.state.config.TIKA_SERVER_URL = ( - form_data.TIKA_SERVER_URL if form_data.TIKA_SERVER_URL is not None else request.app.state.config.TIKA_SERVER_URL + config.TIKA_SERVER_URL = ( + form_data.TIKA_SERVER_URL if form_data.TIKA_SERVER_URL is not None else config.TIKA_SERVER_URL ) - request.app.state.config.DOCLING_SERVER_URL = ( + config.DOCLING_SERVER_URL = ( form_data.DOCLING_SERVER_URL if form_data.DOCLING_SERVER_URL is not None - else request.app.state.config.DOCLING_SERVER_URL + else config.DOCLING_SERVER_URL ) - request.app.state.config.DOCLING_API_KEY = ( - form_data.DOCLING_API_KEY if form_data.DOCLING_API_KEY is not None else request.app.state.config.DOCLING_API_KEY + config.DOCLING_API_KEY = ( + form_data.DOCLING_API_KEY if form_data.DOCLING_API_KEY is not None else config.DOCLING_API_KEY ) - request.app.state.config.DOCLING_PARAMS = ( - form_data.DOCLING_PARAMS if form_data.DOCLING_PARAMS is not None else request.app.state.config.DOCLING_PARAMS + config.DOCLING_PARAMS = ( + form_data.DOCLING_PARAMS if form_data.DOCLING_PARAMS is not None else config.DOCLING_PARAMS ) - request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( + config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( form_data.DOCUMENT_INTELLIGENCE_ENDPOINT if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None - else request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT + else config.DOCUMENT_INTELLIGENCE_ENDPOINT ) - request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = ( + config.DOCUMENT_INTELLIGENCE_KEY = ( form_data.DOCUMENT_INTELLIGENCE_KEY if form_data.DOCUMENT_INTELLIGENCE_KEY is not None - else request.app.state.config.DOCUMENT_INTELLIGENCE_KEY + else config.DOCUMENT_INTELLIGENCE_KEY ) - request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL = ( + config.DOCUMENT_INTELLIGENCE_MODEL = ( form_data.DOCUMENT_INTELLIGENCE_MODEL if form_data.DOCUMENT_INTELLIGENCE_MODEL is not None - else request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL + else config.DOCUMENT_INTELLIGENCE_MODEL ) - request.app.state.config.MISTRAL_OCR_API_BASE_URL = ( + config.MISTRAL_OCR_API_BASE_URL = ( form_data.MISTRAL_OCR_API_BASE_URL if form_data.MISTRAL_OCR_API_BASE_URL is not None - else request.app.state.config.MISTRAL_OCR_API_BASE_URL + else config.MISTRAL_OCR_API_BASE_URL ) - request.app.state.config.MISTRAL_OCR_API_KEY = ( + config.MISTRAL_OCR_API_KEY = ( form_data.MISTRAL_OCR_API_KEY if form_data.MISTRAL_OCR_API_KEY is not None - else request.app.state.config.MISTRAL_OCR_API_KEY + else config.MISTRAL_OCR_API_KEY ) - request.app.state.config.PADDLEOCR_VL_BASE_URL = ( + config.PADDLEOCR_VL_BASE_URL = ( form_data.PADDLEOCR_VL_BASE_URL if form_data.PADDLEOCR_VL_BASE_URL is not None - else request.app.state.config.PADDLEOCR_VL_BASE_URL + else config.PADDLEOCR_VL_BASE_URL ) - request.app.state.config.PADDLEOCR_VL_TOKEN = ( + config.PADDLEOCR_VL_TOKEN = ( form_data.PADDLEOCR_VL_TOKEN if form_data.PADDLEOCR_VL_TOKEN is not None - else request.app.state.config.PADDLEOCR_VL_TOKEN + else config.PADDLEOCR_VL_TOKEN ) # MinerU settings - request.app.state.config.MINERU_API_MODE = ( - form_data.MINERU_API_MODE if form_data.MINERU_API_MODE is not None else request.app.state.config.MINERU_API_MODE + config.MINERU_API_MODE = ( + form_data.MINERU_API_MODE if form_data.MINERU_API_MODE is not None else config.MINERU_API_MODE ) - request.app.state.config.MINERU_API_URL = ( - form_data.MINERU_API_URL if form_data.MINERU_API_URL is not None else request.app.state.config.MINERU_API_URL + config.MINERU_API_URL = ( + form_data.MINERU_API_URL if form_data.MINERU_API_URL is not None else config.MINERU_API_URL ) - request.app.state.config.MINERU_API_KEY = ( - form_data.MINERU_API_KEY if form_data.MINERU_API_KEY is not None else request.app.state.config.MINERU_API_KEY + config.MINERU_API_KEY = ( + form_data.MINERU_API_KEY if form_data.MINERU_API_KEY is not None else config.MINERU_API_KEY ) - request.app.state.config.MINERU_API_TIMEOUT = ( + config.MINERU_API_TIMEOUT = ( form_data.MINERU_API_TIMEOUT if form_data.MINERU_API_TIMEOUT is not None - else request.app.state.config.MINERU_API_TIMEOUT + else config.MINERU_API_TIMEOUT ) - request.app.state.config.MINERU_PARAMS = ( - form_data.MINERU_PARAMS if form_data.MINERU_PARAMS is not None else request.app.state.config.MINERU_PARAMS + config.MINERU_PARAMS = ( + form_data.MINERU_PARAMS if form_data.MINERU_PARAMS is not None else config.MINERU_PARAMS ) - request.app.state.config.MINERU_FILE_EXTENSIONS = ( + config.MINERU_FILE_EXTENSIONS = ( form_data.MINERU_FILE_EXTENSIONS if form_data.MINERU_FILE_EXTENSIONS is not None - else request.app.state.config.MINERU_FILE_EXTENSIONS + else config.MINERU_FILE_EXTENSIONS ) # Reranking settings - if request.app.state.config.RAG_RERANKING_ENGINE == '': + if config.RAG_RERANKING_ENGINE == '': # Unloading the internal reranker and clear VRAM memory request.app.state.rf = None request.app.state.RERANKING_FUNCTION = None @@ -925,68 +1109,68 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend if torch.cuda.is_available(): torch.cuda.empty_cache() - request.app.state.config.RAG_RERANKING_ENGINE = ( + config.RAG_RERANKING_ENGINE = ( form_data.RAG_RERANKING_ENGINE if form_data.RAG_RERANKING_ENGINE is not None - else request.app.state.config.RAG_RERANKING_ENGINE + else config.RAG_RERANKING_ENGINE ) - request.app.state.config.RAG_EXTERNAL_RERANKER_URL = ( + config.RAG_EXTERNAL_RERANKER_URL = ( form_data.RAG_EXTERNAL_RERANKER_URL if form_data.RAG_EXTERNAL_RERANKER_URL is not None - else request.app.state.config.RAG_EXTERNAL_RERANKER_URL + else config.RAG_EXTERNAL_RERANKER_URL ) - request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = ( + config.RAG_EXTERNAL_RERANKER_API_KEY = ( form_data.RAG_EXTERNAL_RERANKER_API_KEY if form_data.RAG_EXTERNAL_RERANKER_API_KEY is not None - else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY + else config.RAG_EXTERNAL_RERANKER_API_KEY ) - request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = ( + config.RAG_EXTERNAL_RERANKER_TIMEOUT = ( form_data.RAG_EXTERNAL_RERANKER_TIMEOUT if form_data.RAG_EXTERNAL_RERANKER_TIMEOUT is not None - else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT + else config.RAG_EXTERNAL_RERANKER_TIMEOUT ) - request.app.state.config.RAG_RERANKING_BATCH_SIZE = ( + config.RAG_RERANKING_BATCH_SIZE = ( form_data.RAG_RERANKING_BATCH_SIZE if form_data.RAG_RERANKING_BATCH_SIZE is not None - else request.app.state.config.RAG_RERANKING_BATCH_SIZE + else config.RAG_RERANKING_BATCH_SIZE ) log.info( - f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}' + f'Updating reranking model: {config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}' ) try: - request.app.state.config.RAG_RERANKING_MODEL = ( + config.RAG_RERANKING_MODEL = ( form_data.RAG_RERANKING_MODEL if form_data.RAG_RERANKING_MODEL is not None - else request.app.state.config.RAG_RERANKING_MODEL + else config.RAG_RERANKING_MODEL ) try: if ( - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH - and not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL + config.ENABLE_RAG_HYBRID_SEARCH + and not config.BYPASS_EMBEDDING_AND_RETRIEVAL ): request.app.state.rf = get_rf( - request.app.state.config.RAG_RERANKING_ENGINE, - request.app.state.config.RAG_RERANKING_MODEL, - request.app.state.config.RAG_EXTERNAL_RERANKER_URL, - request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + config.RAG_RERANKING_ENGINE, + config.RAG_RERANKING_MODEL, + config.RAG_EXTERNAL_RERANKER_URL, + config.RAG_EXTERNAL_RERANKER_API_KEY, + config.RAG_EXTERNAL_RERANKER_TIMEOUT, ) request.app.state.RERANKING_FUNCTION = get_reranking_function( - request.app.state.config.RAG_RERANKING_ENGINE, - request.app.state.config.RAG_RERANKING_MODEL, + config.RAG_RERANKING_ENGINE, + config.RAG_RERANKING_MODEL, request.app.state.rf, - reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE, + reranking_batch_size=config.RAG_RERANKING_BATCH_SIZE, ) except Exception as e: log.error(f'Error loading reranking model: {e}') - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False + config.ENABLE_RAG_HYBRID_SEARCH = False except Exception as e: log.exception(f'Problem updating reranking model: {e}') raise HTTPException( @@ -995,268 +1179,268 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend ) # Chunking settings - request.app.state.config.TEXT_SPLITTER = ( - form_data.TEXT_SPLITTER if form_data.TEXT_SPLITTER is not None else request.app.state.config.TEXT_SPLITTER + config.TEXT_SPLITTER = ( + form_data.TEXT_SPLITTER if form_data.TEXT_SPLITTER is not None else config.TEXT_SPLITTER ) - request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ( + config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ( form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER if form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER is not None - else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER + else config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER ) - request.app.state.config.CHUNK_SIZE = ( - form_data.CHUNK_SIZE if form_data.CHUNK_SIZE is not None else request.app.state.config.CHUNK_SIZE + config.CHUNK_SIZE = ( + form_data.CHUNK_SIZE if form_data.CHUNK_SIZE is not None else config.CHUNK_SIZE ) - request.app.state.config.CHUNK_MIN_SIZE_TARGET = ( + config.CHUNK_MIN_SIZE_TARGET = ( form_data.CHUNK_MIN_SIZE_TARGET if form_data.CHUNK_MIN_SIZE_TARGET is not None - else request.app.state.config.CHUNK_MIN_SIZE_TARGET + else config.CHUNK_MIN_SIZE_TARGET ) - request.app.state.config.CHUNK_OVERLAP = ( - form_data.CHUNK_OVERLAP if form_data.CHUNK_OVERLAP is not None else request.app.state.config.CHUNK_OVERLAP + config.CHUNK_OVERLAP = ( + form_data.CHUNK_OVERLAP if form_data.CHUNK_OVERLAP is not None else config.CHUNK_OVERLAP ) # File upload settings # Empty string means "clear to None" (unlimited/no compression), # None means "don't change", int means "set to this value" if form_data.FILE_MAX_SIZE is not None: - request.app.state.config.FILE_MAX_SIZE = None if form_data.FILE_MAX_SIZE == '' else form_data.FILE_MAX_SIZE + config.FILE_MAX_SIZE = None if form_data.FILE_MAX_SIZE == '' else form_data.FILE_MAX_SIZE if form_data.FILE_MAX_COUNT is not None: - request.app.state.config.FILE_MAX_COUNT = None if form_data.FILE_MAX_COUNT == '' else form_data.FILE_MAX_COUNT + config.FILE_MAX_COUNT = None if form_data.FILE_MAX_COUNT == '' else form_data.FILE_MAX_COUNT if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None: - request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = ( + config.FILE_IMAGE_COMPRESSION_WIDTH = ( None if form_data.FILE_IMAGE_COMPRESSION_WIDTH == '' else form_data.FILE_IMAGE_COMPRESSION_WIDTH ) if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None: - request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = ( + config.FILE_IMAGE_COMPRESSION_HEIGHT = ( None if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == '' else form_data.FILE_IMAGE_COMPRESSION_HEIGHT ) - request.app.state.config.ALLOWED_FILE_EXTENSIONS = ( + config.ALLOWED_FILE_EXTENSIONS = ( form_data.ALLOWED_FILE_EXTENSIONS if form_data.ALLOWED_FILE_EXTENSIONS is not None - else request.app.state.config.ALLOWED_FILE_EXTENSIONS + else config.ALLOWED_FILE_EXTENSIONS ) # Integration settings - request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ( + 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 + else config.ENABLE_GOOGLE_DRIVE_INTEGRATION ) - request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ( + 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 + else config.ENABLE_ONEDRIVE_INTEGRATION ) if form_data.web is not None: # Web search settings - request.app.state.config.ENABLE_WEB_SEARCH = form_data.web.ENABLE_WEB_SEARCH - request.app.state.config.WEB_SEARCH_ENGINE = form_data.web.WEB_SEARCH_ENGINE - request.app.state.config.WEB_SEARCH_TRUST_ENV = form_data.web.WEB_SEARCH_TRUST_ENV - request.app.state.config.WEB_SEARCH_RESULT_COUNT = form_data.web.WEB_SEARCH_RESULT_COUNT - request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS - request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH = form_data.web.WEB_FETCH_MAX_CONTENT_LENGTH - request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = form_data.web.WEB_LOADER_CONCURRENT_REQUESTS - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST - request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( + config.ENABLE_WEB_SEARCH = form_data.web.ENABLE_WEB_SEARCH + config.WEB_SEARCH_ENGINE = form_data.web.WEB_SEARCH_ENGINE + config.WEB_SEARCH_TRUST_ENV = form_data.web.WEB_SEARCH_TRUST_ENV + config.WEB_SEARCH_RESULT_COUNT = form_data.web.WEB_SEARCH_RESULT_COUNT + config.WEB_SEARCH_CONCURRENT_REQUESTS = form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS + config.WEB_FETCH_MAX_CONTENT_LENGTH = form_data.web.WEB_FETCH_MAX_CONTENT_LENGTH + config.WEB_LOADER_CONCURRENT_REQUESTS = form_data.web.WEB_LOADER_CONCURRENT_REQUESTS + config.WEB_SEARCH_DOMAIN_FILTER_LIST = form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST + config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL ) - request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER - request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY - request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL - request.app.state.config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE - request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL - request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME - request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD - request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY - request.app.state.config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID - request.app.state.config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY + config.BYPASS_WEB_SEARCH_WEB_LOADER = form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER + config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY + config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL + config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE + config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL + config.YACY_USERNAME = form_data.web.YACY_USERNAME + config.YACY_PASSWORD = form_data.web.YACY_PASSWORD + config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY + config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID + config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY if form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS is not None: - request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS - request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY - request.app.state.config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY - request.app.state.config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY - request.app.state.config.SERPSTACK_API_KEY = form_data.web.SERPSTACK_API_KEY - request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS - request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY - request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY - request.app.state.config.DDGS_BACKEND = form_data.web.DDGS_BACKEND - request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY - request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY - request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE - request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY - request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE - request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY - request.app.state.config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL - request.app.state.config.BING_SEARCH_V7_ENDPOINT = form_data.web.BING_SEARCH_V7_ENDPOINT - request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = form_data.web.BING_SEARCH_V7_SUBSCRIPTION_KEY - request.app.state.config.EXA_API_KEY = form_data.web.EXA_API_KEY - request.app.state.config.PERPLEXITY_API_KEY = form_data.web.PERPLEXITY_API_KEY - request.app.state.config.PERPLEXITY_MODEL = form_data.web.PERPLEXITY_MODEL - request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE - request.app.state.config.PERPLEXITY_SEARCH_API_URL = form_data.web.PERPLEXITY_SEARCH_API_URL - request.app.state.config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID - request.app.state.config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK + config.BRAVE_SEARCH_CONTEXT_TOKENS = form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS + config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY + config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY + config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY + config.SERPSTACK_API_KEY = form_data.web.SERPSTACK_API_KEY + config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS + config.SERPER_API_KEY = form_data.web.SERPER_API_KEY + config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY + config.DDGS_BACKEND = form_data.web.DDGS_BACKEND + config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY + config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY + config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE + config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY + config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE + config.JINA_API_KEY = form_data.web.JINA_API_KEY + config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL + config.BING_SEARCH_V7_ENDPOINT = form_data.web.BING_SEARCH_V7_ENDPOINT + config.BING_SEARCH_V7_SUBSCRIPTION_KEY = form_data.web.BING_SEARCH_V7_SUBSCRIPTION_KEY + config.EXA_API_KEY = form_data.web.EXA_API_KEY + config.PERPLEXITY_API_KEY = form_data.web.PERPLEXITY_API_KEY + config.PERPLEXITY_MODEL = form_data.web.PERPLEXITY_MODEL + config.PERPLEXITY_SEARCH_CONTEXT_USAGE = form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE + config.PERPLEXITY_SEARCH_API_URL = form_data.web.PERPLEXITY_SEARCH_API_URL + config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID + config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK # Web loader settings - request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE - request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT - - request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION - request.app.state.config.PLAYWRIGHT_WS_URL = form_data.web.PLAYWRIGHT_WS_URL - request.app.state.config.PLAYWRIGHT_TIMEOUT = form_data.web.PLAYWRIGHT_TIMEOUT - request.app.state.config.FIRECRAWL_API_KEY = form_data.web.FIRECRAWL_API_KEY - request.app.state.config.FIRECRAWL_API_BASE_URL = form_data.web.FIRECRAWL_API_BASE_URL - request.app.state.config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT - request.app.state.config.EXTERNAL_WEB_SEARCH_URL = form_data.web.EXTERNAL_WEB_SEARCH_URL - request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = form_data.web.EXTERNAL_WEB_SEARCH_API_KEY - request.app.state.config.EXTERNAL_WEB_LOADER_URL = form_data.web.EXTERNAL_WEB_LOADER_URL - request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = form_data.web.EXTERNAL_WEB_LOADER_API_KEY - request.app.state.config.TAVILY_EXTRACT_DEPTH = form_data.web.TAVILY_EXTRACT_DEPTH - request.app.state.config.YOUTUBE_LOADER_LANGUAGE = form_data.web.YOUTUBE_LOADER_LANGUAGE - request.app.state.config.YOUTUBE_LOADER_PROXY_URL = form_data.web.YOUTUBE_LOADER_PROXY_URL + config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE + config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT + + config.ENABLE_WEB_LOADER_SSL_VERIFICATION = form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION + config.PLAYWRIGHT_WS_URL = form_data.web.PLAYWRIGHT_WS_URL + config.PLAYWRIGHT_TIMEOUT = form_data.web.PLAYWRIGHT_TIMEOUT + config.FIRECRAWL_API_KEY = form_data.web.FIRECRAWL_API_KEY + config.FIRECRAWL_API_BASE_URL = form_data.web.FIRECRAWL_API_BASE_URL + config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT + config.EXTERNAL_WEB_SEARCH_URL = form_data.web.EXTERNAL_WEB_SEARCH_URL + config.EXTERNAL_WEB_SEARCH_API_KEY = form_data.web.EXTERNAL_WEB_SEARCH_API_KEY + config.EXTERNAL_WEB_LOADER_URL = form_data.web.EXTERNAL_WEB_LOADER_URL + config.EXTERNAL_WEB_LOADER_API_KEY = form_data.web.EXTERNAL_WEB_LOADER_API_KEY + config.TAVILY_EXTRACT_DEPTH = form_data.web.TAVILY_EXTRACT_DEPTH + config.YOUTUBE_LOADER_LANGUAGE = form_data.web.YOUTUBE_LOADER_LANGUAGE + config.YOUTUBE_LOADER_PROXY_URL = form_data.web.YOUTUBE_LOADER_PROXY_URL request.app.state.YOUTUBE_LOADER_TRANSLATION = form_data.web.YOUTUBE_LOADER_TRANSLATION - request.app.state.config.YANDEX_WEB_SEARCH_URL = form_data.web.YANDEX_WEB_SEARCH_URL - request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = form_data.web.YANDEX_WEB_SEARCH_API_KEY - request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = form_data.web.YANDEX_WEB_SEARCH_CONFIG - request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY - request.app.state.config.LINKUP_API_KEY = form_data.web.LINKUP_API_KEY - request.app.state.config.LINKUP_SEARCH_PARAMS = form_data.web.LINKUP_SEARCH_PARAMS + config.YANDEX_WEB_SEARCH_URL = form_data.web.YANDEX_WEB_SEARCH_URL + config.YANDEX_WEB_SEARCH_API_KEY = form_data.web.YANDEX_WEB_SEARCH_API_KEY + config.YANDEX_WEB_SEARCH_CONFIG = form_data.web.YANDEX_WEB_SEARCH_CONFIG + config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY + config.LINKUP_API_KEY = form_data.web.LINKUP_API_KEY + config.LINKUP_SEARCH_PARAMS = form_data.web.LINKUP_SEARCH_PARAMS return { 'status': True, # RAG settings - 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, - 'TOP_K': request.app.state.config.TOP_K, - 'BYPASS_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, - 'RAG_FULL_CONTEXT': request.app.state.config.RAG_FULL_CONTEXT, + 'RAG_TEMPLATE': config.RAG_TEMPLATE, + 'TOP_K': config.TOP_K, + 'BYPASS_EMBEDDING_AND_RETRIEVAL': config.BYPASS_EMBEDDING_AND_RETRIEVAL, + 'RAG_FULL_CONTEXT': config.RAG_FULL_CONTEXT, # Hybrid search settings - 'ENABLE_RAG_HYBRID_SEARCH': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - 'TOP_K_RERANKER': request.app.state.config.TOP_K_RERANKER, - 'RELEVANCE_THRESHOLD': request.app.state.config.RELEVANCE_THRESHOLD, - 'HYBRID_BM25_WEIGHT': request.app.state.config.HYBRID_BM25_WEIGHT, + 'ENABLE_RAG_HYBRID_SEARCH': config.ENABLE_RAG_HYBRID_SEARCH, + 'TOP_K_RERANKER': config.TOP_K_RERANKER, + 'RELEVANCE_THRESHOLD': config.RELEVANCE_THRESHOLD, + 'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT, # Content extraction settings - 'CONTENT_EXTRACTION_ENGINE': request.app.state.config.CONTENT_EXTRACTION_ENGINE, - 'PDF_EXTRACT_IMAGES': request.app.state.config.PDF_EXTRACT_IMAGES, - 'PDF_LOADER_MODE': request.app.state.config.PDF_LOADER_MODE, - 'DATALAB_MARKER_API_KEY': request.app.state.config.DATALAB_MARKER_API_KEY, - 'DATALAB_MARKER_API_BASE_URL': request.app.state.config.DATALAB_MARKER_API_BASE_URL, - 'DATALAB_MARKER_ADDITIONAL_CONFIG': request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, - 'DATALAB_MARKER_SKIP_CACHE': request.app.state.config.DATALAB_MARKER_SKIP_CACHE, - 'DATALAB_MARKER_FORCE_OCR': request.app.state.config.DATALAB_MARKER_FORCE_OCR, - 'DATALAB_MARKER_PAGINATE': request.app.state.config.DATALAB_MARKER_PAGINATE, - 'DATALAB_MARKER_STRIP_EXISTING_OCR': request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, - 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - 'DATALAB_MARKER_USE_LLM': request.app.state.config.DATALAB_MARKER_USE_LLM, - 'DATALAB_MARKER_OUTPUT_FORMAT': request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, - 'EXTERNAL_DOCUMENT_LOADER_URL': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, - 'EXTERNAL_DOCUMENT_LOADER_API_KEY': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - 'TIKA_SERVER_URL': request.app.state.config.TIKA_SERVER_URL, - 'DOCLING_SERVER_URL': request.app.state.config.DOCLING_SERVER_URL, - 'DOCLING_API_KEY': request.app.state.config.DOCLING_API_KEY, - 'DOCLING_PARAMS': request.app.state.config.DOCLING_PARAMS, - 'DOCUMENT_INTELLIGENCE_ENDPOINT': request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, - 'DOCUMENT_INTELLIGENCE_KEY': request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, - 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, - 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, - 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, - 'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL, - 'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN, + 'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE, + 'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES, + 'PDF_LOADER_MODE': config.PDF_LOADER_MODE, + 'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY, + 'DATALAB_MARKER_API_BASE_URL': config.DATALAB_MARKER_API_BASE_URL, + 'DATALAB_MARKER_ADDITIONAL_CONFIG': config.DATALAB_MARKER_ADDITIONAL_CONFIG, + 'DATALAB_MARKER_SKIP_CACHE': config.DATALAB_MARKER_SKIP_CACHE, + 'DATALAB_MARKER_FORCE_OCR': config.DATALAB_MARKER_FORCE_OCR, + 'DATALAB_MARKER_PAGINATE': config.DATALAB_MARKER_PAGINATE, + 'DATALAB_MARKER_STRIP_EXISTING_OCR': config.DATALAB_MARKER_STRIP_EXISTING_OCR, + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + 'DATALAB_MARKER_USE_LLM': config.DATALAB_MARKER_USE_LLM, + 'DATALAB_MARKER_OUTPUT_FORMAT': config.DATALAB_MARKER_OUTPUT_FORMAT, + 'EXTERNAL_DOCUMENT_LOADER_URL': config.EXTERNAL_DOCUMENT_LOADER_URL, + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, + 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, + 'DOCLING_API_KEY': config.DOCLING_API_KEY, + 'DOCLING_PARAMS': config.DOCLING_PARAMS, + 'DOCUMENT_INTELLIGENCE_ENDPOINT': config.DOCUMENT_INTELLIGENCE_ENDPOINT, + 'DOCUMENT_INTELLIGENCE_KEY': config.DOCUMENT_INTELLIGENCE_KEY, + 'DOCUMENT_INTELLIGENCE_MODEL': config.DOCUMENT_INTELLIGENCE_MODEL, + 'MISTRAL_OCR_API_BASE_URL': config.MISTRAL_OCR_API_BASE_URL, + 'MISTRAL_OCR_API_KEY': config.MISTRAL_OCR_API_KEY, + 'PADDLEOCR_VL_BASE_URL': config.PADDLEOCR_VL_BASE_URL, + 'PADDLEOCR_VL_TOKEN': config.PADDLEOCR_VL_TOKEN, # MinerU settings - 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, - 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, - 'MINERU_API_KEY': request.app.state.config.MINERU_API_KEY, - 'MINERU_API_TIMEOUT': request.app.state.config.MINERU_API_TIMEOUT, - 'MINERU_PARAMS': request.app.state.config.MINERU_PARAMS, + 'MINERU_API_MODE': config.MINERU_API_MODE, + 'MINERU_API_URL': config.MINERU_API_URL, + 'MINERU_API_KEY': config.MINERU_API_KEY, + 'MINERU_API_TIMEOUT': config.MINERU_API_TIMEOUT, + 'MINERU_PARAMS': config.MINERU_PARAMS, # Reranking settings - 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, - 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, - 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, - 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + 'RAG_RERANKING_MODEL': config.RAG_RERANKING_MODEL, + 'RAG_RERANKING_ENGINE': config.RAG_RERANKING_ENGINE, + 'RAG_EXTERNAL_RERANKER_URL': config.RAG_EXTERNAL_RERANKER_URL, + 'RAG_EXTERNAL_RERANKER_API_KEY': config.RAG_EXTERNAL_RERANKER_API_KEY, + 'RAG_EXTERNAL_RERANKER_TIMEOUT': config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings - 'TEXT_SPLITTER': request.app.state.config.TEXT_SPLITTER, - 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, - 'CHUNK_MIN_SIZE_TARGET': request.app.state.config.CHUNK_MIN_SIZE_TARGET, - 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, - 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, + 'TEXT_SPLITTER': config.TEXT_SPLITTER, + 'CHUNK_SIZE': config.CHUNK_SIZE, + 'CHUNK_MIN_SIZE_TARGET': config.CHUNK_MIN_SIZE_TARGET, + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, + 'CHUNK_OVERLAP': config.CHUNK_OVERLAP, # File upload settings - 'FILE_MAX_SIZE': request.app.state.config.FILE_MAX_SIZE, - 'FILE_MAX_COUNT': request.app.state.config.FILE_MAX_COUNT, - 'FILE_IMAGE_COMPRESSION_WIDTH': request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - 'FILE_IMAGE_COMPRESSION_HEIGHT': request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, - 'ALLOWED_FILE_EXTENSIONS': request.app.state.config.ALLOWED_FILE_EXTENSIONS, + 'FILE_MAX_SIZE': config.FILE_MAX_SIZE, + 'FILE_MAX_COUNT': config.FILE_MAX_COUNT, + 'FILE_IMAGE_COMPRESSION_WIDTH': config.FILE_IMAGE_COMPRESSION_WIDTH, + 'FILE_IMAGE_COMPRESSION_HEIGHT': config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'ALLOWED_FILE_EXTENSIONS': config.ALLOWED_FILE_EXTENSIONS, # Integration settings - 'ENABLE_GOOGLE_DRIVE_INTEGRATION': request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - 'ENABLE_ONEDRIVE_INTEGRATION': request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + 'ENABLE_GOOGLE_DRIVE_INTEGRATION': config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'ENABLE_ONEDRIVE_INTEGRATION': config.ENABLE_ONEDRIVE_INTEGRATION, # Web search settings 'web': { - 'ENABLE_WEB_SEARCH': request.app.state.config.ENABLE_WEB_SEARCH, - 'WEB_SEARCH_ENGINE': request.app.state.config.WEB_SEARCH_ENGINE, - 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, - 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, - 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - 'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH, - 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - 'BYPASS_WEB_SEARCH_WEB_LOADER': request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, - 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, - 'SEARXNG_QUERY_URL': request.app.state.config.SEARXNG_QUERY_URL, - 'SEARXNG_LANGUAGE': request.app.state.config.SEARXNG_LANGUAGE, - 'YACY_QUERY_URL': request.app.state.config.YACY_QUERY_URL, - 'YACY_USERNAME': request.app.state.config.YACY_USERNAME, - 'YACY_PASSWORD': request.app.state.config.YACY_PASSWORD, - 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, - 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, - 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, - 'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, - 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, - 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, - 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, - 'SERPSTACK_API_KEY': request.app.state.config.SERPSTACK_API_KEY, - 'SERPSTACK_HTTPS': request.app.state.config.SERPSTACK_HTTPS, - 'SERPER_API_KEY': request.app.state.config.SERPER_API_KEY, - 'SERPLY_API_KEY': request.app.state.config.SERPLY_API_KEY, - 'TAVILY_API_KEY': request.app.state.config.TAVILY_API_KEY, - 'SEARCHAPI_API_KEY': request.app.state.config.SEARCHAPI_API_KEY, - 'SEARCHAPI_ENGINE': request.app.state.config.SEARCHAPI_ENGINE, - 'SERPAPI_API_KEY': request.app.state.config.SERPAPI_API_KEY, - 'SERPAPI_ENGINE': request.app.state.config.SERPAPI_ENGINE, - 'JINA_API_KEY': request.app.state.config.JINA_API_KEY, - 'JINA_API_BASE_URL': request.app.state.config.JINA_API_BASE_URL, - '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, - 'PERPLEXITY_MODEL': request.app.state.config.PERPLEXITY_MODEL, - 'PERPLEXITY_SEARCH_CONTEXT_USAGE': request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, - 'PERPLEXITY_SEARCH_API_URL': request.app.state.config.PERPLEXITY_SEARCH_API_URL, - 'SOUGOU_API_SID': request.app.state.config.SOUGOU_API_SID, - 'SOUGOU_API_SK': request.app.state.config.SOUGOU_API_SK, - 'WEB_LOADER_ENGINE': request.app.state.config.WEB_LOADER_ENGINE, - 'WEB_LOADER_TIMEOUT': request.app.state.config.WEB_LOADER_TIMEOUT, - 'ENABLE_WEB_LOADER_SSL_VERIFICATION': request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - 'PLAYWRIGHT_WS_URL': request.app.state.config.PLAYWRIGHT_WS_URL, - 'PLAYWRIGHT_TIMEOUT': request.app.state.config.PLAYWRIGHT_TIMEOUT, - 'FIRECRAWL_API_KEY': request.app.state.config.FIRECRAWL_API_KEY, - 'FIRECRAWL_API_BASE_URL': request.app.state.config.FIRECRAWL_API_BASE_URL, - 'FIRECRAWL_TIMEOUT': request.app.state.config.FIRECRAWL_TIMEOUT, - 'TAVILY_EXTRACT_DEPTH': request.app.state.config.TAVILY_EXTRACT_DEPTH, - 'EXTERNAL_WEB_SEARCH_URL': request.app.state.config.EXTERNAL_WEB_SEARCH_URL, - 'EXTERNAL_WEB_SEARCH_API_KEY': request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, - 'EXTERNAL_WEB_LOADER_URL': request.app.state.config.EXTERNAL_WEB_LOADER_URL, - 'EXTERNAL_WEB_LOADER_API_KEY': request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, - 'YOUTUBE_LOADER_LANGUAGE': request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - 'YOUTUBE_LOADER_PROXY_URL': request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + 'ENABLE_WEB_SEARCH': config.ENABLE_WEB_SEARCH, + 'WEB_SEARCH_ENGINE': config.WEB_SEARCH_ENGINE, + 'WEB_SEARCH_TRUST_ENV': config.WEB_SEARCH_TRUST_ENV, + 'WEB_SEARCH_RESULT_COUNT': config.WEB_SEARCH_RESULT_COUNT, + 'WEB_SEARCH_CONCURRENT_REQUESTS': config.WEB_SEARCH_CONCURRENT_REQUESTS, + 'WEB_FETCH_MAX_CONTENT_LENGTH': config.WEB_FETCH_MAX_CONTENT_LENGTH, + 'WEB_LOADER_CONCURRENT_REQUESTS': config.WEB_LOADER_CONCURRENT_REQUESTS, + 'WEB_SEARCH_DOMAIN_FILTER_LIST': config.WEB_SEARCH_DOMAIN_FILTER_LIST, + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'BYPASS_WEB_SEARCH_WEB_LOADER': config.BYPASS_WEB_SEARCH_WEB_LOADER, + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL, + 'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE, + 'YACY_QUERY_URL': config.YACY_QUERY_URL, + 'YACY_USERNAME': config.YACY_USERNAME, + 'YACY_PASSWORD': config.YACY_PASSWORD, + 'GOOGLE_PSE_API_KEY': config.GOOGLE_PSE_API_KEY, + 'GOOGLE_PSE_ENGINE_ID': config.GOOGLE_PSE_ENGINE_ID, + 'BRAVE_SEARCH_API_KEY': config.BRAVE_SEARCH_API_KEY, + 'BRAVE_SEARCH_CONTEXT_TOKENS': config.BRAVE_SEARCH_CONTEXT_TOKENS, + 'KAGI_SEARCH_API_KEY': config.KAGI_SEARCH_API_KEY, + 'MOJEEK_SEARCH_API_KEY': config.MOJEEK_SEARCH_API_KEY, + 'BOCHA_SEARCH_API_KEY': config.BOCHA_SEARCH_API_KEY, + 'SERPSTACK_API_KEY': config.SERPSTACK_API_KEY, + 'SERPSTACK_HTTPS': config.SERPSTACK_HTTPS, + 'SERPER_API_KEY': config.SERPER_API_KEY, + 'SERPLY_API_KEY': config.SERPLY_API_KEY, + 'TAVILY_API_KEY': config.TAVILY_API_KEY, + 'SEARCHAPI_API_KEY': config.SEARCHAPI_API_KEY, + 'SEARCHAPI_ENGINE': config.SEARCHAPI_ENGINE, + 'SERPAPI_API_KEY': config.SERPAPI_API_KEY, + 'SERPAPI_ENGINE': config.SERPAPI_ENGINE, + 'JINA_API_KEY': config.JINA_API_KEY, + 'JINA_API_BASE_URL': config.JINA_API_BASE_URL, + 'BING_SEARCH_V7_ENDPOINT': config.BING_SEARCH_V7_ENDPOINT, + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'EXA_API_KEY': config.EXA_API_KEY, + 'PERPLEXITY_API_KEY': config.PERPLEXITY_API_KEY, + 'PERPLEXITY_MODEL': config.PERPLEXITY_MODEL, + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': config.PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'PERPLEXITY_SEARCH_API_URL': config.PERPLEXITY_SEARCH_API_URL, + 'SOUGOU_API_SID': config.SOUGOU_API_SID, + 'SOUGOU_API_SK': config.SOUGOU_API_SK, + 'WEB_LOADER_ENGINE': config.WEB_LOADER_ENGINE, + 'WEB_LOADER_TIMEOUT': config.WEB_LOADER_TIMEOUT, + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'PLAYWRIGHT_WS_URL': config.PLAYWRIGHT_WS_URL, + 'PLAYWRIGHT_TIMEOUT': config.PLAYWRIGHT_TIMEOUT, + 'FIRECRAWL_API_KEY': config.FIRECRAWL_API_KEY, + 'FIRECRAWL_API_BASE_URL': config.FIRECRAWL_API_BASE_URL, + 'FIRECRAWL_TIMEOUT': config.FIRECRAWL_TIMEOUT, + 'TAVILY_EXTRACT_DEPTH': config.TAVILY_EXTRACT_DEPTH, + 'EXTERNAL_WEB_SEARCH_URL': config.EXTERNAL_WEB_SEARCH_URL, + 'EXTERNAL_WEB_SEARCH_API_KEY': config.EXTERNAL_WEB_SEARCH_API_KEY, + 'EXTERNAL_WEB_LOADER_URL': config.EXTERNAL_WEB_LOADER_URL, + 'EXTERNAL_WEB_LOADER_API_KEY': config.EXTERNAL_WEB_LOADER_API_KEY, + 'YOUTUBE_LOADER_LANGUAGE': config.YOUTUBE_LOADER_LANGUAGE, + 'YOUTUBE_LOADER_PROXY_URL': config.YOUTUBE_LOADER_PROXY_URL, 'YOUTUBE_LOADER_TRANSLATION': request.app.state.YOUTUBE_LOADER_TRANSLATION, - 'YANDEX_WEB_SEARCH_URL': request.app.state.config.YANDEX_WEB_SEARCH_URL, - 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, - 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, - 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, - 'LINKUP_API_KEY': request.app.state.config.LINKUP_API_KEY, - 'LINKUP_SEARCH_PARAMS': request.app.state.config.LINKUP_SEARCH_PARAMS, + 'YANDEX_WEB_SEARCH_URL': config.YANDEX_WEB_SEARCH_URL, + 'YANDEX_WEB_SEARCH_API_KEY': config.YANDEX_WEB_SEARCH_API_KEY, + 'YANDEX_WEB_SEARCH_CONFIG': config.YANDEX_WEB_SEARCH_CONFIG, + 'YOUCOM_API_KEY': config.YOUCOM_API_KEY, + 'LINKUP_API_KEY': config.LINKUP_API_KEY, + 'LINKUP_SEARCH_PARAMS': config.LINKUP_SEARCH_PARAMS, }, } @@ -1284,6 +1468,7 @@ def can_merge_chunks(a: Document, b: Document) -> bool: def merge_docs_to_target_size( request: Request, chunks: list[Document], + config: RetrievalConfig, ) -> list[Document]: """ Best-effort normalization of chunk sizes. @@ -1296,15 +1481,15 @@ def merge_docs_to_target_size( backward merging (append into the previous emitted chunk) for undersized chunks that can't grow forward. """ - min_size = request.app.state.config.CHUNK_MIN_SIZE_TARGET - max_size = request.app.state.config.CHUNK_SIZE + min_size = config.CHUNK_MIN_SIZE_TARGET + max_size = config.CHUNK_SIZE if min_size <= 0: return chunks measure: Callable[[str], int] = len - if request.app.state.config.TEXT_SPLITTER == 'token': - encoding = tiktoken.get_encoding(str(request.app.state.config.TIKTOKEN_ENCODING_NAME)) + if config.TEXT_SPLITTER == 'token': + encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME)) measure = lambda text: len(encoding.encode(text)) def _merge_backward(result: list[Document], content: str, chunk: Document) -> bool: @@ -1362,6 +1547,7 @@ def save_docs_to_vector_db( request: Request, docs, collection_name, + config: RetrievalConfig, metadata: dict | None = None, overwrite: bool = False, split: bool = True, @@ -1408,7 +1594,7 @@ def _get_docs_info(docs: list[Document]) -> str: raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) if split: - if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: + if config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: log.info('Using markdown header text splitter') # Define headers to split on - covering most common markdown header levels markdown_splitter = MarkdownHeaderTextSplitter( @@ -1436,24 +1622,24 @@ def _get_docs_info(docs: list[Document]) -> str: ) docs = split_docs - if request.app.state.config.CHUNK_MIN_SIZE_TARGET > 0: - docs = merge_docs_to_target_size(request, docs) + if config.CHUNK_MIN_SIZE_TARGET > 0: + docs = merge_docs_to_target_size(request, docs, config) - if request.app.state.config.TEXT_SPLITTER in ['', 'character']: + if config.TEXT_SPLITTER in ['', 'character']: text_splitter = RecursiveCharacterTextSplitter( - chunk_size=request.app.state.config.CHUNK_SIZE, - chunk_overlap=request.app.state.config.CHUNK_OVERLAP, + chunk_size=config.CHUNK_SIZE, + chunk_overlap=config.CHUNK_OVERLAP, add_start_index=True, ) docs = text_splitter.split_documents(docs) - elif request.app.state.config.TEXT_SPLITTER == 'token': - log.info(f'Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}') + elif config.TEXT_SPLITTER == 'token': + log.info(f'Using token text splitter: {config.TIKTOKEN_ENCODING_NAME}') - tiktoken.get_encoding(str(request.app.state.config.TIKTOKEN_ENCODING_NAME)) + tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME)) text_splitter = TokenTextSplitter( - encoding_name=str(request.app.state.config.TIKTOKEN_ENCODING_NAME), - chunk_size=request.app.state.config.CHUNK_SIZE, - chunk_overlap=request.app.state.config.CHUNK_OVERLAP, + encoding_name=str(config.TIKTOKEN_ENCODING_NAME), + chunk_size=config.CHUNK_SIZE, + chunk_overlap=config.CHUNK_OVERLAP, add_start_index=True, ) docs = text_splitter.split_documents(docs) @@ -1469,8 +1655,8 @@ def _get_docs_info(docs: list[Document]) -> str: **doc.metadata, **(metadata if metadata else {}), 'embedding_config': { - 'engine': request.app.state.config.RAG_EMBEDDING_ENGINE, - 'model': request.app.state.config.RAG_EMBEDDING_MODEL, + 'engine': config.RAG_EMBEDDING_ENGINE, + 'model': config.RAG_EMBEDDING_MODEL, }, } for doc in docs @@ -1489,35 +1675,35 @@ def _get_docs_info(docs: list[Document]) -> str: log.info(f'generating embeddings for {collection_name}') embedding_function = get_embedding_function( - request.app.state.config.RAG_EMBEDDING_ENGINE, - request.app.state.config.RAG_EMBEDDING_MODEL, + config.RAG_EMBEDDING_ENGINE, + config.RAG_EMBEDDING_MODEL, request.app.state.ef, ( - request.app.state.config.RAG_OPENAI_API_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' + config.RAG_OPENAI_API_BASE_URL + if config.RAG_EMBEDDING_ENGINE == 'openai' else ( - request.app.state.config.RAG_OLLAMA_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL + config.RAG_OLLAMA_BASE_URL + if config.RAG_EMBEDDING_ENGINE == 'ollama' + else config.RAG_AZURE_OPENAI_BASE_URL ) ), ( - request.app.state.config.RAG_OPENAI_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' + config.RAG_OPENAI_API_KEY + if config.RAG_EMBEDDING_ENGINE == 'openai' else ( - request.app.state.config.RAG_OLLAMA_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' - else request.app.state.config.RAG_AZURE_OPENAI_API_KEY + config.RAG_OLLAMA_API_KEY + if config.RAG_EMBEDDING_ENGINE == 'ollama' + else config.RAG_AZURE_OPENAI_API_KEY ) ), - request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + config.RAG_EMBEDDING_BATCH_SIZE, azure_api_version=( - request.app.state.config.RAG_AZURE_OPENAI_API_VERSION - if request.app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' + config.RAG_AZURE_OPENAI_API_VERSION + if config.RAG_EMBEDDING_ENGINE == 'azure_openai' else None ), - enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, - concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + enable_async=config.ENABLE_ASYNC_EMBEDDING, + concurrent_requests=config.RAG_EMBEDDING_CONCURRENT_REQUESTS, ) # Run async embedding in sync context using the main event loop @@ -1577,6 +1763,7 @@ async def process_file( Note: granular session management is used to prevent connection pool exhaustion. The session is committed before external API calls, and updates use a fresh session. """ + config = await get_retrieval_config() if user.role == 'admin': file = await Files.get_file_by_id(form_data.file_id, db=db) else: @@ -1653,7 +1840,8 @@ async def process_file( file_path = file.path if file_path: file_path = await asyncio.to_thread(Storage.get_file, file_path) - loader = build_loader_from_config(request) + loader_config = await get_loader_config() + loader = build_loader_from_config(request, loader_config) loader.user = user docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path) @@ -1693,7 +1881,7 @@ async def process_file( ) hash = calculate_sha256_string(text_content) - if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + if config.BYPASS_EMBEDDING_AND_RETRIEVAL: await Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db) await Files.update_file_hash_by_id(file.id, hash, db=db) return { @@ -1719,6 +1907,7 @@ async def process_file( request, docs=docs, collection_name=collection_name, + config=config, metadata={ 'file_id': file.id, 'name': file.filename, @@ -1812,7 +2001,8 @@ async def process_text( text_content = form_data.content log.debug(f'text_content: {text_content}') - result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, user=user) + config = await get_retrieval_config() + result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, config, user=user) if result: return { 'status': True, @@ -1835,6 +2025,7 @@ async def process_web( overwrite: bool = Query(True, description='Whether to overwrite existing collection'), user=Depends(get_verified_user), ): + config = await get_retrieval_config() try: content, docs = await run_in_threadpool(get_content_from_url, request, form_data.url) log.debug(f'text_content: {content}') @@ -1846,12 +2037,13 @@ async def process_web( else: await _validate_collection_access([collection_name], user, access_type='write') - if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + if not config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: await run_in_threadpool( save_docs_to_vector_db, request, docs, collection_name, + config, overwrite=overwrite, add=(not overwrite), user=user, @@ -1895,149 +2087,150 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li """ # TODO: add playwright to search the web + config = await get_retrieval_config() if engine == 'ollama_cloud': return await asyncio.to_thread( search_ollama_cloud, 'https://ollama.com', - request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == 'perplexity_search': - if request.app.state.config.PERPLEXITY_API_KEY: + if config.PERPLEXITY_API_KEY: return await asyncio.to_thread( search_perplexity_search, - request.app.state.config.PERPLEXITY_API_KEY, + config.PERPLEXITY_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - request.app.state.config.PERPLEXITY_SEARCH_API_URL, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.PERPLEXITY_SEARCH_API_URL, user, ) else: raise Exception('No PERPLEXITY_API_KEY found in environment variables') elif engine == 'searxng': - if request.app.state.config.SEARXNG_QUERY_URL: - searxng_kwargs = {'language': request.app.state.config.SEARXNG_LANGUAGE} + if config.SEARXNG_QUERY_URL: + searxng_kwargs = {'language': config.SEARXNG_LANGUAGE} return await search_searxng( - request.app.state.config.SEARXNG_QUERY_URL, + config.SEARXNG_QUERY_URL, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, **searxng_kwargs, ) else: raise Exception('No SEARXNG_QUERY_URL found in environment variables') elif engine == 'yacy': - if request.app.state.config.YACY_QUERY_URL: + if config.YACY_QUERY_URL: return await asyncio.to_thread( search_yacy, - request.app.state.config.YACY_QUERY_URL, - request.app.state.config.YACY_USERNAME, - request.app.state.config.YACY_PASSWORD, + config.YACY_QUERY_URL, + config.YACY_USERNAME, + config.YACY_PASSWORD, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No YACY_QUERY_URL found in environment variables') elif engine == 'google_pse': - if request.app.state.config.GOOGLE_PSE_API_KEY and request.app.state.config.GOOGLE_PSE_ENGINE_ID: + if config.GOOGLE_PSE_API_KEY and config.GOOGLE_PSE_ENGINE_ID: return await search_google_pse( - request.app.state.config.GOOGLE_PSE_API_KEY, - request.app.state.config.GOOGLE_PSE_ENGINE_ID, + config.GOOGLE_PSE_API_KEY, + config.GOOGLE_PSE_ENGINE_ID, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - referer=request.app.state.config.WEBUI_URL, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + referer=config.WEBUI_URL, ) else: raise Exception('No GOOGLE_PSE_API_KEY or GOOGLE_PSE_ENGINE_ID found in environment variables') elif engine == 'brave': - if request.app.state.config.BRAVE_SEARCH_API_KEY: + if config.BRAVE_SEARCH_API_KEY: return await search_brave( - request.app.state.config.BRAVE_SEARCH_API_KEY, + config.BRAVE_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables') elif engine == 'brave_llm_context': - if request.app.state.config.BRAVE_SEARCH_API_KEY: + if config.BRAVE_SEARCH_API_KEY: return await asyncio.to_thread( search_brave_llm_context, - request.app.state.config.BRAVE_SEARCH_API_KEY, + config.BRAVE_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.BRAVE_SEARCH_CONTEXT_TOKENS, ) else: raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables') elif engine == 'kagi': - if request.app.state.config.KAGI_SEARCH_API_KEY: + if config.KAGI_SEARCH_API_KEY: return await asyncio.to_thread( search_kagi, - request.app.state.config.KAGI_SEARCH_API_KEY, + config.KAGI_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No KAGI_SEARCH_API_KEY found in environment variables') elif engine == 'mojeek': - if request.app.state.config.MOJEEK_SEARCH_API_KEY: + if config.MOJEEK_SEARCH_API_KEY: return await asyncio.to_thread( search_mojeek, - request.app.state.config.MOJEEK_SEARCH_API_KEY, + config.MOJEEK_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No MOJEEK_SEARCH_API_KEY found in environment variables') elif engine == 'bocha': - if request.app.state.config.BOCHA_SEARCH_API_KEY: + if config.BOCHA_SEARCH_API_KEY: return await asyncio.to_thread( search_bocha, - request.app.state.config.BOCHA_SEARCH_API_KEY, + config.BOCHA_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No BOCHA_SEARCH_API_KEY found in environment variables') elif engine == 'serpstack': - if request.app.state.config.SERPSTACK_API_KEY: + if config.SERPSTACK_API_KEY: return await search_serpstack( - request.app.state.config.SERPSTACK_API_KEY, + config.SERPSTACK_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - https_enabled=request.app.state.config.SERPSTACK_HTTPS, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + https_enabled=config.SERPSTACK_HTTPS, ) else: raise Exception('No SERPSTACK_API_KEY found in environment variables') elif engine == 'serper': - if request.app.state.config.SERPER_API_KEY: + if config.SERPER_API_KEY: return await search_serper( - request.app.state.config.SERPER_API_KEY, + config.SERPER_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No SERPER_API_KEY found in environment variables') elif engine == 'serply': - if request.app.state.config.SERPLY_API_KEY: + if config.SERPLY_API_KEY: return await asyncio.to_thread( search_serply, - request.app.state.config.SERPLY_API_KEY, + config.SERPLY_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + filter_list=config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No SERPLY_API_KEY found in environment variables') @@ -2045,89 +2238,89 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li return await asyncio.to_thread( search_duckduckgo, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - backend=request.app.state.config.DDGS_BACKEND, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + concurrent_requests=config.WEB_SEARCH_CONCURRENT_REQUESTS, + backend=config.DDGS_BACKEND, ) elif engine == 'tavily': - if request.app.state.config.TAVILY_API_KEY: + if config.TAVILY_API_KEY: return await asyncio.to_thread( search_tavily, - request.app.state.config.TAVILY_API_KEY, + config.TAVILY_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No TAVILY_API_KEY found in environment variables') elif engine == 'exa': - if request.app.state.config.EXA_API_KEY: + if config.EXA_API_KEY: return await asyncio.to_thread( search_exa, - request.app.state.config.EXA_API_KEY, + config.EXA_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No EXA_API_KEY found in environment variables') elif engine == 'searchapi': - if request.app.state.config.SEARCHAPI_API_KEY: + if config.SEARCHAPI_API_KEY: return await asyncio.to_thread( search_searchapi, - request.app.state.config.SEARCHAPI_API_KEY, - request.app.state.config.SEARCHAPI_ENGINE, + config.SEARCHAPI_API_KEY, + config.SEARCHAPI_ENGINE, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No SEARCHAPI_API_KEY found in environment variables') elif engine == 'serpapi': - if request.app.state.config.SERPAPI_API_KEY: + if config.SERPAPI_API_KEY: return await asyncio.to_thread( search_serpapi, - request.app.state.config.SERPAPI_API_KEY, - request.app.state.config.SERPAPI_ENGINE, + config.SERPAPI_API_KEY, + config.SERPAPI_ENGINE, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No SERPAPI_API_KEY found in environment variables') elif engine == 'jina': return await asyncio.to_thread( search_jina, - request.app.state.config.JINA_API_KEY, + config.JINA_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.JINA_API_BASE_URL, + config.WEB_SEARCH_RESULT_COUNT, + config.JINA_API_BASE_URL, ) elif engine == 'bing': return await asyncio.to_thread( search_bing, - request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, - request.app.state.config.BING_SEARCH_V7_ENDPOINT, + config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + config.BING_SEARCH_V7_ENDPOINT, str(DEFAULT_LOCALE), query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == 'azure': if ( - request.app.state.config.AZURE_AI_SEARCH_API_KEY - and request.app.state.config.AZURE_AI_SEARCH_ENDPOINT - and request.app.state.config.AZURE_AI_SEARCH_INDEX_NAME + config.AZURE_AI_SEARCH_API_KEY + and config.AZURE_AI_SEARCH_ENDPOINT + and config.AZURE_AI_SEARCH_INDEX_NAME ): return await asyncio.to_thread( search_azure, - request.app.state.config.AZURE_AI_SEARCH_API_KEY, - request.app.state.config.AZURE_AI_SEARCH_ENDPOINT, - request.app.state.config.AZURE_AI_SEARCH_INDEX_NAME, + config.AZURE_AI_SEARCH_API_KEY, + config.AZURE_AI_SEARCH_ENDPOINT, + config.AZURE_AI_SEARCH_INDEX_NAME, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception( @@ -2136,74 +2329,74 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li elif engine == 'perplexity': return await asyncio.to_thread( search_perplexity, - request.app.state.config.PERPLEXITY_API_KEY, + config.PERPLEXITY_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - model=request.app.state.config.PERPLEXITY_MODEL, - search_context_usage=request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + model=config.PERPLEXITY_MODEL, + search_context_usage=config.PERPLEXITY_SEARCH_CONTEXT_USAGE, ) elif engine == 'sougou': - if request.app.state.config.SOUGOU_API_SID and request.app.state.config.SOUGOU_API_SK: + if config.SOUGOU_API_SID and config.SOUGOU_API_SK: return await asyncio.to_thread( search_sougou, - request.app.state.config.SOUGOU_API_SID, - request.app.state.config.SOUGOU_API_SK, + config.SOUGOU_API_SID, + config.SOUGOU_API_SK, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception('No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables') elif engine == 'firecrawl': return await asyncio.to_thread( search_firecrawl, - request.app.state.config.FIRECRAWL_API_BASE_URL, - request.app.state.config.FIRECRAWL_API_KEY, + config.FIRECRAWL_API_BASE_URL, + config.FIRECRAWL_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == 'external': return await asyncio.to_thread( search_external, request, - request.app.state.config.EXTERNAL_WEB_SEARCH_URL, - request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + config.EXTERNAL_WEB_SEARCH_URL, + config.EXTERNAL_WEB_SEARCH_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, user=user, ) elif engine == 'yandex': return await asyncio.to_thread( search_yandex, request, - request.app.state.config.YANDEX_WEB_SEARCH_URL, - request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, - request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, + config.YANDEX_WEB_SEARCH_URL, + config.YANDEX_WEB_SEARCH_API_KEY, + config.YANDEX_WEB_SEARCH_CONFIG, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, user=user, ) elif engine == 'youcom': return await asyncio.to_thread( search_youcom, - request.app.state.config.YOUCOM_API_KEY, + config.YOUCOM_API_KEY, query, - request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == 'linkup': - if request.app.state.config.LINKUP_API_KEY: + if config.LINKUP_API_KEY: return await asyncio.to_thread( search_linkup, - api_key=request.app.state.config.LINKUP_API_KEY, + api_key=config.LINKUP_API_KEY, query=query, - count=request.app.state.config.WEB_SEARCH_RESULT_COUNT, - filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - params=request.app.state.config.LINKUP_SEARCH_PARAMS, + count=config.WEB_SEARCH_RESULT_COUNT, + filter_list=config.WEB_SEARCH_DOMAIN_FILTER_LIST, + params=config.LINKUP_SEARCH_PARAMS, ) else: raise Exception('No LINKUP_API_KEY found in environment variables') @@ -2213,14 +2406,15 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li @router.post('/process/web/search') async def process_web_search(request: Request, form_data: SearchForm, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_WEB_SEARCH: + config = await get_retrieval_config() + if not config.ENABLE_WEB_SEARCH: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) if user.role != 'admin' and not await has_permission( - user.id, 'features.web_search', request.app.state.config.USER_PERMISSIONS + user.id, 'features.web_search', config.USER_PERMISSIONS ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -2231,12 +2425,12 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen result_items = [] try: - logging.debug(f'trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}') + logging.debug(f'trying to web search with {config.WEB_SEARCH_ENGINE, form_data.queries}') # Use semaphore to limit concurrent requests based on WEB_SEARCH_CONCURRENT_REQUESTS # 0 or None = unlimited (previous behavior), positive number = limited concurrency # Set to 1 for sequential execution (rate-limited APIs like Brave free tier) - concurrent_limit = request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS + concurrent_limit = config.WEB_SEARCH_CONCURRENT_REQUESTS if concurrent_limit: # Limited concurrency with semaphore @@ -2246,7 +2440,7 @@ async def search_query_with_semaphore(query): async with semaphore: return await search_web( request, - request.app.state.config.WEB_SEARCH_ENGINE, + config.WEB_SEARCH_ENGINE, query, user, ) @@ -2257,7 +2451,7 @@ async def search_query_with_semaphore(query): search_tasks = [ search_web( request, - request.app.state.config.WEB_SEARCH_ENGINE, + config.WEB_SEARCH_ENGINE, query, user, ) @@ -2287,7 +2481,7 @@ async def search_query_with_semaphore(query): ) try: - if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER: + if config.BYPASS_WEB_SEARCH_WEB_LOADER: search_results = [item for result in search_results for item in result if result] docs = [ @@ -2306,9 +2500,9 @@ async def search_query_with_semaphore(query): else: loader = get_web_loader( urls, - verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV, + verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS, + trust_env=config.WEB_SEARCH_TRUST_ENV, ) docs = await loader.aload() @@ -2319,7 +2513,7 @@ async def search_query_with_semaphore(query): dict(item) for item in result_items if item.link in urls ] # only keep the search results that have been loaded - if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + if config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: return { 'status': True, 'collection_name': None, @@ -2344,6 +2538,7 @@ async def search_query_with_semaphore(query): request, docs, collection_name, + config, overwrite=True, user=user, ) @@ -2393,10 +2588,11 @@ async def query_doc_handler( form_data: QueryDocForm, user=Depends(get_verified_user), ): + config = await get_retrieval_config() await _validate_collection_access([form_data.collection_name], user) try: - if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): + if config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): collection_results = {} collection_results[form_data.collection_name] = await ASYNC_VECTOR_DB_CLIENT.get( collection_name=form_data.collection_name @@ -2408,18 +2604,18 @@ async def query_doc_handler( 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, + k=form_data.k if form_data.k else config.TOP_K, reranking_function=( (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=form_data.k_reranker or request.app.state.config.TOP_K_RERANKER, - r=(form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD), + k_reranker=form_data.k_reranker or config.TOP_K_RERANKER, + r=(form_data.r if form_data.r else config.RELEVANCE_THRESHOLD), hybrid_bm25_weight=( form_data.hybrid_bm25_weight if form_data.hybrid_bm25_weight - else request.app.state.config.HYBRID_BM25_WEIGHT + else config.HYBRID_BM25_WEIGHT ), user=user, ) @@ -2433,7 +2629,7 @@ async def query_doc_handler( query_doc, collection_name=form_data.collection_name, query_embedding=query_embedding, - k=form_data.k if form_data.k else request.app.state.config.TOP_K, + k=form_data.k if form_data.k else config.TOP_K, user=user, ) except Exception as e: @@ -2461,33 +2657,34 @@ async def query_collection_handler( form_data: QueryCollectionsForm, user=Depends(get_verified_user), ): + config = await get_retrieval_config() await _validate_collection_access(form_data.collection_names, user) try: - if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): + if config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): return await query_collection_with_hybrid_search( collection_names=form_data.collection_names, queries=[form_data.query], 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, + k=form_data.k if form_data.k else config.TOP_K, reranking_function=( (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=form_data.k_reranker or request.app.state.config.TOP_K_RERANKER, - r=(form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD), + k_reranker=form_data.k_reranker or config.TOP_K_RERANKER, + r=(form_data.r if form_data.r else config.RELEVANCE_THRESHOLD), hybrid_bm25_weight=( form_data.hybrid_bm25_weight if form_data.hybrid_bm25_weight - else request.app.state.config.HYBRID_BM25_WEIGHT + else config.HYBRID_BM25_WEIGHT ), enable_enriched_texts=( form_data.enable_enriched_texts if form_data.enable_enriched_texts is not None - else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS + else config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS ), ) else: @@ -2498,7 +2695,7 @@ async def query_collection_handler( 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, + k=form_data.k if form_data.k else config.TOP_K, ) except Exception as e: @@ -2641,6 +2838,7 @@ async def process_files_batch( embedding (Files.update_file_by_id) manage their own short-lived sessions. """ + config = await get_retrieval_config() collection_name = form_data.collection_name if collection_name: @@ -2712,6 +2910,7 @@ async def process_files_batch( request, all_docs, collection_name, + config, add=True, user=user, ) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index e36afef1736..6e2ebb892ba 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -259,10 +259,6 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False) log.info(f'SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}') - # Handle both ConfigVar and direct value - if hasattr(enable_scim, 'value'): - enable_scim = enable_scim.value - if not enable_scim: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -271,9 +267,6 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) # Verify the SCIM token scim_token = getattr(request.app.state, 'SCIM_TOKEN', None) - # Handle both ConfigVar and direct value - if hasattr(scim_token, 'value'): - scim_token = scim_token.value log.debug(f'SCIM token configured: {bool(scim_token)}') if not scim_token or not hmac.compare_digest(token, scim_token): raise HTTPException( diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 55aa351ff05..ae6c210e29a 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -6,6 +6,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.skills import ( SkillAccessListResponse, @@ -130,7 +131,7 @@ async def export_skills( if user.role != 'admin' and not await has_permission( user.id, 'workspace.skills', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ): raise HTTPException( @@ -157,7 +158,7 @@ async def create_new_skill( db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'workspace.skills', await Config.get('user.permissions'), db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -179,7 +180,7 @@ async def create_new_skill( # grants in the create payload, bypassing the sharing.public_skills gate # that the dedicated /access/update endpoint already enforces. form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -292,7 +293,7 @@ async def update_skill_by_id( # they may set, so a non-admin owner cannot make their own skill publicly # readable/writable without sharing.public_skills permission. form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -361,7 +362,7 @@ async def update_skill_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 0e88f8594a5..9caaf9e1f79 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -16,6 +16,7 @@ DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, ) from open_webui.constants import ERROR_MESSAGES, TASKS +from open_webui.models.config import Config from open_webui.routers.pipelines import process_pipeline_inlet_filter from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat import generate_chat_completion @@ -36,6 +37,35 @@ router = APIRouter() +TASK_CONFIG_KEYS = { + 'TASK_MODEL': 'task.model.default', + 'TASK_MODEL_EXTERNAL': 'task.model.external', + 'TITLE_GENERATION_PROMPT_TEMPLATE': 'task.title.prompt_template', + 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': 'task.image.prompt_template', + 'ENABLE_AUTOCOMPLETE_GENERATION': 'task.autocomplete.enable', + 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': 'task.autocomplete.input_max_length', + 'TAGS_GENERATION_PROMPT_TEMPLATE': 'task.tags.prompt_template', + 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': 'task.follow_up.prompt_template', + 'ENABLE_FOLLOW_UP_GENERATION': 'task.follow_up.enable', + 'ENABLE_TAGS_GENERATION': 'task.tags.enable', + 'ENABLE_TITLE_GENERATION': 'task.title.enable', + 'ENABLE_SEARCH_QUERY_GENERATION': 'task.query.search.enable', + 'ENABLE_RETRIEVAL_QUERY_GENERATION': 'task.query.retrieval.enable', + 'QUERY_GENERATION_PROMPT_TEMPLATE': 'task.query.prompt_template', + 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': 'task.tools.prompt_template', + 'ENABLE_VOICE_MODE_PROMPT': 'task.voice.prompt.enable', + 'VOICE_MODE_PROMPT_TEMPLATE': 'task.voice.prompt_template', +} + + +async def get_config_values(key_map: dict[str, str]) -> dict: + values = await Config.get_many(*key_map.values()) + return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values} + + +def config_updates(data: dict, key_map: dict[str, str]) -> dict: + return {key_map[field]: value for field, value in data.items() if field in key_map} + ################################## # @@ -59,25 +89,7 @@ async def check_active_chats(request: Request, form_data: ActiveChatsForm, user= @router.get('/config') async def get_task_config(request: Request, user=Depends(get_verified_user)): - return { - 'TASK_MODEL': request.app.state.config.TASK_MODEL, - 'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL, - 'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, - 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, - 'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, - 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, - 'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, - 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, - 'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, - 'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION, - 'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION, - 'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, - 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, - 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, - 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, - 'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT, - 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, - } + return await get_config_values(TASK_CONFIG_KEYS) class TaskConfigForm(BaseModel): @@ -102,56 +114,13 @@ class TaskConfigForm(BaseModel): @router.post('/config/update') async def update_task_config(request: Request, form_data: TaskConfigForm, user=Depends(get_admin_user)): - request.app.state.config.TASK_MODEL = form_data.TASK_MODEL - request.app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL - request.app.state.config.ENABLE_TITLE_GENERATION = form_data.ENABLE_TITLE_GENERATION - request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = form_data.TITLE_GENERATION_PROMPT_TEMPLATE - - request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = form_data.ENABLE_FOLLOW_UP_GENERATION - request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE - - request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE - - request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = form_data.ENABLE_AUTOCOMPLETE_GENERATION - request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ( - form_data.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH - ) - - request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = form_data.TAGS_GENERATION_PROMPT_TEMPLATE - request.app.state.config.ENABLE_TAGS_GENERATION = form_data.ENABLE_TAGS_GENERATION - request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION = form_data.ENABLE_SEARCH_QUERY_GENERATION - request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = form_data.ENABLE_RETRIEVAL_QUERY_GENERATION - - request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE - request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE - - request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT - request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE - - return { - 'TASK_MODEL': request.app.state.config.TASK_MODEL, - 'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL, - 'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION, - 'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, - 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, - 'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, - 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, - 'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, - 'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION, - 'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, - 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, - 'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, - 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, - 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, - 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, - 'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT, - 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, - } + await Config.upsert(config_updates(form_data.model_dump(), TASK_CONFIG_KEYS)) + return await get_config_values(TASK_CONFIG_KEYS) @router.post('/title/completions') async def generate_title(request: Request, form_data: dict, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_TITLE_GENERATION: + if not await Config.get('task.title.enable'): return JSONResponse( status_code=status.HTTP_200_OK, content={'detail': 'Title generation is disabled'}, @@ -181,15 +150,16 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') - if request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != '': - template = request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE + title_template = await Config.get('task.title.prompt_template') + if title_template != '': + template = title_template else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE @@ -234,7 +204,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver @router.post('/follow_up/completions') async def generate_follow_ups(request: Request, form_data: dict, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_FOLLOW_UP_GENERATION: + if not await Config.get('task.follow_up.enable'): return JSONResponse( status_code=status.HTTP_200_OK, content={'detail': 'Follow-up generation is disabled'}, @@ -259,15 +229,16 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') - if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != '': - template = request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE + follow_up_template = await Config.get('task.follow_up.prompt_template') + if follow_up_template != '': + template = follow_up_template else: template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE @@ -303,7 +274,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge @router.post('/tags/completions') async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_TAGS_GENERATION: + if not await Config.get('task.tags.enable'): return JSONResponse( status_code=status.HTTP_200_OK, content={'detail': 'Tags generation is disabled'}, @@ -328,15 +299,16 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating chat tags using model {task_model_id} for user {user.email} ') - if request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != '': - template = request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE + tags_template = await Config.get('task.tags.prompt_template') + if tags_template != '': + template = tags_template else: template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE @@ -391,15 +363,16 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating image prompt using model {task_model_id} for user {user.email} ') - if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != '': - template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE + image_prompt_template = await Config.get('task.image.prompt_template') + if image_prompt_template != '': + template = image_prompt_template else: template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE @@ -437,13 +410,13 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( async def generate_queries(request: Request, form_data: dict, user=Depends(get_verified_user)): type = form_data.get('type') if type == 'web_search': - if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION: + if not await Config.get('task.query.search.enable'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'), ) elif type == 'retrieval': - if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION: + if not await Config.get('task.query.retrieval.enable'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'), @@ -472,15 +445,16 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating {type} queries using model {task_model_id} for user {user.email}') - if (request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != '': - template = request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE + query_template = await Config.get('task.query.prompt_template') + if query_template.strip() != '': + template = query_template else: template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE @@ -515,7 +489,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v @router.post('/auto/completions') async def generate_autocompletion(request: Request, form_data: dict, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION: + if not await Config.get('task.autocomplete.enable'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'), @@ -525,11 +499,12 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend prompt = form_data.get('prompt') messages = form_data.get('messages') - if request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH > 0: - if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: + autocomplete_input_max_length = await Config.get('task.autocomplete.input_max_length') + if autocomplete_input_max_length > 0: + if len(prompt) > autocomplete_input_max_length: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH), + detail=ERROR_MESSAGES.INPUT_TOO_LONG(autocomplete_input_max_length), ) if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): @@ -551,15 +526,16 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) log.debug(f'generating autocompletion using model {task_model_id} for user {user.email}') - if (request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != '': - template = request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE + autocomplete_template = await Config.get('task.autocomplete.prompt_template') + if autocomplete_template.strip() != '': + template = autocomplete_template else: template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE @@ -614,8 +590,8 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver # If the user has a custom task model, use that model task_model_id = get_task_model_id( model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 7dc91ba8a55..269e0cd7642 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from open_webui.config import TERMINAL_PROXY_HEADERS from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.users import Users from open_webui.utils.access_control import has_connection_access @@ -62,7 +63,7 @@ def _sanitize_proxy_path(path: str) -> str | None: @router.get('/') async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): """Return terminal servers the authenticated user has access to.""" - connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connections = await Config.get('terminal_server.connections', []) or [] user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} return [ @@ -87,7 +88,7 @@ async def proxy_terminal( user=Depends(get_verified_user), ): """Proxy a request to the admin terminal server identified by *server_id*.""" - connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connections = await Config.get('terminal_server.connections', []) or [] connection = next((c for c in connections if c.get('id') == server_id), None) if connection is None: @@ -235,7 +236,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): return None # Resolve terminal server - connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connections = await Config.get('terminal_server.connections', []) or [] connection = next((c for c in connections if c.get('id') == server_id), None) if connection is None: diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 963a727cde5..abd49dd64fa 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -13,6 +13,7 @@ from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.tools import ( @@ -84,7 +85,7 @@ async def get_tools( server_access_grants = {} for server in await get_tool_servers(request): server_idx = server.get('idx', 0) - connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + connections = await Config.get('tool_server.connections', []) if server_idx >= len(connections): log.warning( f'Tool server index {server_idx} out of range ' @@ -113,7 +114,7 @@ async def get_tools( ) # MCP Tool Servers - for server in request.app.state.config.TOOL_SERVER_CONNECTIONS: + for server in await Config.get('tool_server.connections', []): if server.get('type', 'openapi') == 'mcp' and server.get('config', {}).get('enable'): server_id = server.get('info', {}).get('id') auth_type = server.get('auth_type', 'none') @@ -303,7 +304,7 @@ async def export_tools( if user.role != 'admin' and not await has_permission( user.id, 'workspace.tools_export', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ): raise HTTPException( @@ -331,11 +332,11 @@ async def create_new_tools( ): """Create a new tool from user-supplied Python source code.""" if user.role != 'admin' and not ( - await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) + await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db) or await has_permission( user.id, 'workspace.tools_import', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), db=db, ) ): @@ -356,7 +357,7 @@ async def create_new_tools( if tools is None: try: form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -484,8 +485,8 @@ async def update_tools_by_id( # Content edits trigger exec on load — gate them behind workspace.tools (matches /create). if form_data.content != tools.content: if user.role != 'admin' and not ( - await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) - or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db) + await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db) + or await has_permission(user.id, 'workspace.tools_import', await Config.get('user.permissions'), db=db) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -503,7 +504,7 @@ async def update_tools_by_id( specs = get_tool_specs(TOOLS[id]) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, @@ -574,7 +575,7 @@ async def update_tool_access_by_id( ) form_data.access_grants = await filter_allowed_access_grants( - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index cb60e4cb330..53d500477c0 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -12,6 +12,7 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR from open_webui.internal.db import get_async_session from open_webui.models.auths import Auths +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import ( @@ -157,7 +158,7 @@ async def get_user_permissisions( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db) return user_permissions @@ -239,7 +240,7 @@ class FeaturesPermissions(BaseModel): memories: bool = True automations: bool = False calendar: bool = True - user_webhooks: bool = False + webhooks: bool = False class SettingsPermissions(BaseModel): @@ -257,20 +258,22 @@ class UserPermissions(BaseModel): @router.get('/default/permissions', response_model=UserPermissions) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): + user_permissions = await Config.get('user.permissions') return { - 'workspace': WorkspacePermissions(**request.app.state.config.USER_PERMISSIONS.get('workspace', {})), - 'sharing': SharingPermissions(**request.app.state.config.USER_PERMISSIONS.get('sharing', {})), - 'access_grants': AccessGrantsPermissions(**request.app.state.config.USER_PERMISSIONS.get('access_grants', {})), - 'chat': ChatPermissions(**request.app.state.config.USER_PERMISSIONS.get('chat', {})), - 'features': FeaturesPermissions(**request.app.state.config.USER_PERMISSIONS.get('features', {})), - 'settings': SettingsPermissions(**request.app.state.config.USER_PERMISSIONS.get('settings', {})), + 'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})), + 'sharing': SharingPermissions(**user_permissions.get('sharing', {})), + 'access_grants': AccessGrantsPermissions(**user_permissions.get('access_grants', {})), + 'chat': ChatPermissions(**user_permissions.get('chat', {})), + 'features': FeaturesPermissions(**user_permissions.get('features', {})), + 'settings': SettingsPermissions(**user_permissions.get('settings', {})), } @router.post('/default/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(by_alias=True) - return request.app.state.config.USER_PERMISSIONS + user_permissions = form_data.model_dump(by_alias=True) + await Config.upsert({'user.permissions': user_permissions}) + return user_permissions @router.get('/default/permissions/defaults', response_model=UserPermissions) @@ -321,7 +324,7 @@ async def update_user_settings_by_session_user( and not await has_permission( user.id, 'features.direct_tool_servers', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), ) ): # If the user is not an admin and does not have permission to use tool servers, remove the key @@ -348,7 +351,7 @@ async def get_user_status_by_session_user( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if not request.app.state.config.ENABLE_USER_STATUS: + if not await Config.get('users.enable_status'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, @@ -369,7 +372,7 @@ async def update_user_status_by_session_user( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if not request.app.state.config.ENABLE_USER_STATUS: + if not await Config.get('users.enable_status'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index 4d0f6799555..dcad54f4b10 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -7,6 +7,7 @@ from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES from open_webui.models.chats import ChatTitleMessagesForm +from open_webui.models.config import Config 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.utils.misc import get_gravatar_url @@ -41,27 +42,27 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)): @router.post('/code/execute') async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)): - if not request.app.state.config.ENABLE_CODE_EXECUTION: + if not await Config.get('code_execution.enable'): raise HTTPException( status_code=403, detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'), ) - if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter': + if await Config.get('code_execution.engine') == 'jupyter': output = await execute_code_jupyter( - request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + await Config.get('code_execution.jupyter.url'), form_data.code, ( - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN - if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'token' + await Config.get('code_execution.jupyter.auth_token') + if await Config.get('code_execution.jupyter.auth') == 'token' else None ), ( - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD - if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'password' + await Config.get('code_execution.jupyter.auth_password') + if await Config.get('code_execution.jupyter.auth') == 'password' else None ), - request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, + await Config.get('code_execution.jupyter.timeout'), ) return output diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 965f333dfcc..47d1657df7b 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -18,6 +18,7 @@ from open_webui.models.channels import Channel, ChannelMember, Channels from open_webui.models.chats import Chats +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.memories import Memories from open_webui.models.messages import Message, Messages @@ -225,10 +226,10 @@ async def search_web( return json.dumps({'error': 'Request context not available'}) try: - engine = __request__.app.state.config.WEB_SEARCH_ENGINE + engine = await Config.get('rag.web.search.engine') user = UserModel(**__user__) if __user__ else None - configured = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT + configured = await Config.get('rag.web.search.result_count') max_count = 5 if configured is None else configured count = max(1, min(count, max_count)) if count is not None else max_count @@ -266,7 +267,7 @@ async def fetch_url( # Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH) # Guard: content may be None if the web loader silently failed if content is not None: - max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None) + max_length = await Config.get('rag.web.fetch.max_content_length') if max_length and max_length > 0 and len(content) > max_length: content = content[:max_length] + '\n\n[Content truncated...]' else: @@ -475,7 +476,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): ) code = blocking_code + '\n' + code - engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') + engine = await Config.get('code_interpreter.engine', 'pyodide') if engine == 'pyodide': # Execute via frontend pyodide using bidirectional event call if __event_call__ is None: @@ -513,21 +514,22 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): elif engine == 'jupyter': from open_webui.utils.code_interpreter import execute_code_jupyter + jupyter_auth = await Config.get('code_interpreter.jupyter.auth') output = await execute_code_jupyter( - __request__.app.state.config.CODE_INTERPRETER_JUPYTER_URL, + await Config.get('code_interpreter.jupyter.url'), code, ( - __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token' + await Config.get('code_interpreter.jupyter.auth_token') + if jupyter_auth == 'token' else None ), ( - __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password' + await Config.get('code_interpreter.jupyter.auth_password') + if jupyter_auth == 'password' else None ), - __request__.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, + await Config.get('code_interpreter.jupyter.timeout'), ) stdout = output.get('stdout', '') diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index d513a5c6716..a98f95f97ea 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -114,7 +114,7 @@ async def has_access( Check if a user has the specified permission using an in-memory access_grants list. Used for config-driven resources (arena models, tool servers) that store - access control as JSON in ConfigVar rather than in the access_grant DB table. + access control as JSON config rather than in the access_grant DB table. Semantics: - None or [] → private (owner-only, deny all) diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 3594b62abe8..1ed539fefa6 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -39,6 +39,7 @@ from fastapi.security import HTTPAuthorizationCredentials from open_webui.env import CUSTOM_API_KEY_HEADER from open_webui.internal.db import ScopedSession +from open_webui.models.config import Config from open_webui.utils.auth import get_http_authorization_cred from starlette.datastructures import MutableHeaders from starlette.requests import Request @@ -165,7 +166,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) request.state.token = token - request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS + request.state.enable_api_keys = await Config.get('auth.enable_api_keys') async def send_with_timing(message: Message) -> None: if message['type'] == 'http.response.start': diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 26cea6b45fc..e7a9a0ad1b9 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -35,6 +35,7 @@ pk, ) from open_webui.models.auths import Auths +from open_webui.models.config import Config from open_webui.models.users import Users from open_webui.utils.access_control import has_permission from pytz import UTC @@ -409,12 +410,16 @@ async def get_current_user_by_api_key(request, api_key: str): detail=ERROR_MESSAGES.INVALID_TOKEN, ) + user_permissions = await Config.get('user.permissions') + enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions') + allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '') + if not request.state.enable_api_keys or ( user.role != 'admin' and not await has_permission( user.id, 'features.api_keys', - request.app.state.config.USER_PERMISSIONS, + user_permissions, ) ): raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) @@ -422,10 +427,8 @@ async def get_current_user_by_api_key(request, api_key: str): # Enforce endpoint restrictions — checked here (not in middleware) # so it applies regardless of how the API key was transported # (Authorization header, cookie, x-api-key header, etc.). - if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: - allowed_paths = [ - path.strip() for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip() - ] + if enable_endpoint_restrictions: + allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()] request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710) is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths) if not is_allowed: diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 7db4d6e3ca5..e651c426d87 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -29,6 +29,7 @@ from open_webui.internal.db import get_async_db from open_webui.models.automations import AutomationModel, AutomationRuns, Automations from open_webui.models.chats import ChatForm, Chats +from open_webui.models.config import Config from open_webui.models.users import Users from open_webui.utils.task import prompt_template from starlette.datastructures import Headers @@ -172,7 +173,7 @@ async def scheduler_worker_loop(app) -> None: while True: try: # ── Automations ── - if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False): + if await Config.get('automations.enable'): try: async with get_async_db() as db: batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db) @@ -184,7 +185,7 @@ async def scheduler_worker_loop(app) -> None: log.exception('Scheduler: automation error') # ── Calendar Alerts ── - if getattr(app.state.config, 'ENABLE_CALENDAR', False): + if await Config.get('calendar.enable'): try: await _check_calendar_alerts(app) except Exception: @@ -239,7 +240,7 @@ def _resolve_model_tool_ids(app, model_id: str) -> list[str]: return list(tool_ids) if tool_ids else [] -def _resolve_model_features(app, model_id: str) -> dict: +async def _resolve_model_features(app, model_id: str) -> dict: """Read model default features from model config. The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds @@ -256,14 +257,13 @@ def _resolve_model_features(app, model_id: str) -> dict: return {} capabilities = meta.get('capabilities', {}) - config = app.state.config features = {} # code_interpreter is excluded: it requires the frontend event emitter # and does not work in headless backend execution. feature_checks = { - 'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False), - 'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False), + 'web_search': await Config.get('rag.web.search.enable'), + 'image_generation': await Config.get('image_generation.enable'), } for feature_id in default_feature_ids: @@ -364,7 +364,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: if user.role not in ('user', 'admin') or ( user.role != 'admin' - and not await has_permission(user.id, 'features.automations', app.state.config.USER_PERMISSIONS) + and not await has_permission(user.id, 'features.automations', await Config.get('user.permissions')) ): await _record_run(automation.id, 'error', error='Owner no longer permitted to run automations') return @@ -436,7 +436,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: # Resolve model defaults (frontend does this, backend doesn't) tool_ids = _resolve_model_tool_ids(app, model_id) - features = _resolve_model_features(app, model_id) + features = await _resolve_model_features(app, model_id) filter_ids = _resolve_model_filter_ids(app, model_id) # Resolve terminal from model config @@ -561,7 +561,7 @@ async def _check_calendar_alerts(app) -> None: # Send webhook notification if user has one configured try: webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI') - enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False) + enable_user_webhooks = await Config.get('ui.enable_user_webhooks') if enable_user_webhooks: user = await Users.get_user_by_id(event.user_id) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a0eb1ad8e00..1036ae9ca6e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -40,6 +40,7 @@ RAG_SYSTEM_CONTEXT, ) from open_webui.models.chats import Chats +from open_webui.models.config import Config from open_webui.models.folders import Folders from open_webui.models.functions import Functions from open_webui.models.models import Models @@ -980,13 +981,13 @@ async def apply_source_context_to_messages( if RAG_SYSTEM_CONTEXT: return add_or_update_system_message( - await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + await rag_template(await Config.get('rag.template'), context, user_message), messages, append=True, ) else: return add_or_update_user_message( - await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + await rag_template(await Config.get('rag.template'), context, user_message), messages, append=False, ) @@ -1290,8 +1291,8 @@ def get_tools_function_calling_payload(messages, task_model_id, content): task_model_id = get_task_model_id( body['model'], - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) @@ -1301,8 +1302,8 @@ def get_tools_function_calling_payload(messages, task_model_id, content): specs = [tool['spec'] for tool in tools.values()] tools_specs = json.dumps(specs, ensure_ascii=False) - if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != '': - template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + if await Config.get('task.tools.prompt_template') != '': + template = await Config.get('task.tools.prompt_template') else: template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE @@ -1792,7 +1793,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra system_message_content = '' - if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT: + if len(input_images) > 0 and await Config.get('images.edit.enable'): # Edit image(s) try: images = await image_edits( @@ -1852,7 +1853,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra else: # Create image(s) - if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION: + if await Config.get('image_generation.prompt.enable'): try: res = await generate_image_prompt( request, @@ -2018,17 +2019,17 @@ async def chat_completion_files_handler( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( query, prefix=prefix, user=user ), - k=request.app.state.config.TOP_K, + k=await Config.get('rag.top_k'), reranking_function=( (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=request.app.state.config.TOP_K_RERANKER, - r=request.app.state.config.RELEVANCE_THRESHOLD, - hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, - hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - full_context=all_full_context or request.app.state.config.RAG_FULL_CONTEXT, + k_reranker=await Config.get('rag.top_k_reranker'), + r=await Config.get('rag.relevance_threshold'), + hybrid_bm25_weight=await Config.get('rag.hybrid_bm25_weight'), + hybrid_search=await Config.get('rag.enable_hybrid_search'), + full_context=all_full_context or await Config.get('rag.full_context'), user=user, ) except Exception as e: @@ -2269,7 +2270,7 @@ async def connect_mcp_server( Returns None if the server is not found or access is denied. """ mcp_server_connection = None - for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: + for server_connection in await Config.get('tool_server.connections', []): if server_connection.get('type', '') == 'mcp' and server_connection.get('info', {}).get('id') == server_id: mcp_server_connection = server_connection break @@ -2442,8 +2443,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): task_model_id = get_task_model_id( form_data['model'], - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, + await Config.get('task.model.default'), + await Config.get('task.model.external'), models, ) @@ -2550,9 +2551,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): extra_params['__features__'] = features if features: if 'voice' in features and features['voice']: - if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True): - if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE: - template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE + if await Config.get('task.voice.prompt.enable'): + if await Config.get('task.voice.prompt_template'): + template = await Config.get('task.voice.prompt_template') else: template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE @@ -2577,14 +2578,14 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data = await chat_image_generation_handler(request, form_data, extra_params, user) if 'code_interpreter' in features and features['code_interpreter']: - engine = getattr(request.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') + engine = await Config.get('code_interpreter.engine', 'pyodide') # Skip XML-tag prompt injection when native FC is enabled — # execute_code will be injected as a builtin tool instead if metadata.get('params', {}).get('function_calling') == 'legacy': prompt = ( - request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE - if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != '' + await Config.get('code_interpreter.prompt_template') + if await Config.get('code_interpreter.prompt_template') != '' else DEFAULT_CODE_INTERPRETER_PROMPT ) @@ -3534,18 +3535,19 @@ async def non_streaming_chat_response_handler(response, ctx): ) # Send a webhook notification if the user is not active - if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id): webhook_url = await Users.get_user_webhook_url_by_id(user.id) if webhook_url: + webui_url = await Config.get('webui.url') await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, 'title': title, - 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + 'url': f'{webui_url}/c/{metadata["chat_id"]}', }, ) @@ -3883,14 +3885,14 @@ def full_output(): DETECT_CODE_INTERPRETER = ( bool(features.get('code_interpreter')) and builtin_tools_meta.get('code_interpreter', True) - and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True) + and await Config.get('code_interpreter.enable') and model_capabilities.get('code_interpreter', True) and ( getattr(user, 'role', None) == 'admin' or await has_permission( getattr(user, 'id', ''), 'features.code_interpreter', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), ) ) ) @@ -4784,7 +4786,7 @@ async def flush_pending_delta_data(threshold: int = 0): source_context = source_context.strip() if source_context: rag_content = await rag_template( - request.app.state.config.RAG_TEMPLATE, + await Config.get('rag.template'), source_context, user_message, ) @@ -4978,7 +4980,7 @@ async def restricted_import(name, globals=None, locals=None, fromlist=(), level= """) code = blocking_code + '\n' + code - if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide': + if await Config.get('code_interpreter.engine') == 'pyodide': ci_output = await event_caller( { 'type': 'execute:python', @@ -4990,21 +4992,21 @@ async def restricted_import(name, globals=None, locals=None, fromlist=(), level= }, } ) - elif request.app.state.config.CODE_INTERPRETER_ENGINE == 'jupyter': + elif await Config.get('code_interpreter.engine') == 'jupyter': ci_output = await execute_code_jupyter( - request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, + await Config.get('code_interpreter.jupyter.url'), code, ( - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token' + await Config.get('code_interpreter.jupyter.auth_token') + if await Config.get('code_interpreter.jupyter.auth') == 'token' else None ), ( - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password' + await Config.get('code_interpreter.jupyter.auth_password') + if await Config.get('code_interpreter.jupyter.auth') == 'password' else None ), - request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, + await Config.get('code_interpreter.jupyter.timeout'), ) else: ci_output = {'stdout': 'Code interpreter engine not configured.'} @@ -5148,18 +5150,19 @@ async def restricted_import(name, globals=None, locals=None, fromlist=(), level= ) # Send a webhook notification if the user is not active - if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id): webhook_url = await Users.get_user_webhook_url_by_id(user.id) if webhook_url: + webui_url = await Config.get('webui.url') await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, 'title': title, - 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + 'url': f'{webui_url}/c/{metadata["chat_id"]}', }, ) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index a014267f383..e960f5503e8 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -12,6 +12,7 @@ from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL from open_webui.functions import get_function_models from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.functions import Functions from open_webui.models.groups import Groups from open_webui.models.models import Models @@ -52,14 +53,15 @@ async def fetch_openai_models(request: Request, user: UserModel = None): async def get_all_base_models(request: Request, user: UserModel = None): + config = await Config.get_many('openai.enable', 'ollama.enable') openai_task = ( fetch_openai_models(request, user) - if request.app.state.config.ENABLE_OPENAI_API + if config.get('openai.enable') else asyncio.sleep(0, result=[]) ) ollama_task = ( fetch_ollama_models(request, user) - if request.app.state.config.ENABLE_OLLAMA_API + if config.get('ollama.enable') else asyncio.sleep(0, result=[]) ) function_task = get_function_models(request) @@ -70,10 +72,15 @@ async def get_all_base_models(request: Request, user: UserModel = None): async def get_all_models(request, refresh: bool = False, user: UserModel = None): + config = await Config.get_many( + 'models.base_models_cache', + 'evaluation.arena.enable', + 'evaluation.arena.models', + ) if ( request.app.state.MODELS and request.app.state.BASE_MODELS - and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh) + and (config.get('models.base_models_cache') and not refresh) ): base_models = request.app.state.BASE_MODELS else: @@ -88,9 +95,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) return [] # Add arena models - if request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS: + if config.get('evaluation.arena.enable'): arena_models = [] - if len(request.app.state.config.EVALUATION_ARENA_MODELS) > 0: + arena_config = config.get('evaluation.arena.models') or [] + if len(arena_config) > 0: arena_models = [ { 'id': model['id'], @@ -103,7 +111,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'owned_by': 'arena', 'arena': True, } - for model in request.app.state.config.EVALUATION_ARENA_MODELS + for model in arena_config ] else: # Add default arena model @@ -289,7 +297,7 @@ def get_filter_items_from_module(function, module): # Apply global model defaults to all models # Per-model overrides take precedence over global defaults - default_metadata = getattr(request.app.state.config, 'DEFAULT_MODEL_METADATA', None) or {} + default_metadata = await Config.get('models.default_metadata', {}) or {} if default_metadata: for model in models: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index dfddd487e44..882e92efdd6 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1,18 +1,17 @@ import base64 -import copy import fnmatch import hashlib import json import logging import mimetypes import re -import secrets import sys import time import urllib import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta +from types import SimpleNamespace from typing import Literal, Optional import aiohttp @@ -62,7 +61,6 @@ OAUTH_UPDATE_PICTURE_ON_LOGIN, OAUTH_USERNAME_CLAIM, WEBHOOK_URL, - AppConfig, ) from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( @@ -78,6 +76,7 @@ WEBUI_NAME, ) from open_webui.models.auths import Auths +from open_webui.models.config import Config from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import Users @@ -111,31 +110,73 @@ class OAuthClientInformationFull(OAuthClientMetadata): logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -auth_manager_config = AppConfig() -auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE -auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP -auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE -auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL -auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT -auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT -auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION -auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE -auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS -auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM -auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM -auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM -auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM -auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM -auth_manager_config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM -auth_manager_config.OAUTH_ALLOWED_ROLES = OAUTH_ALLOWED_ROLES -auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES -auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS -auth_manager_config.WEBHOOK_URL = WEBHOOK_URL -auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN -auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN -auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN -auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN -auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE +OAUTH_RUNTIME_CONFIG = { + 'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE), + 'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP), + 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': ( + 'oauth.refresh_token.include_scope', + OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, + ), + 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': ( + 'oauth.merge_accounts_by_email', + OAUTH_MERGE_ACCOUNTS_BY_EMAIL, + ), + 'ENABLE_OAUTH_ROLE_MANAGEMENT': ( + 'oauth.enable_role_mapping', + ENABLE_OAUTH_ROLE_MANAGEMENT, + ), + 'ENABLE_OAUTH_GROUP_MANAGEMENT': ( + 'oauth.enable_group_mapping', + ENABLE_OAUTH_GROUP_MANAGEMENT, + ), + 'ENABLE_OAUTH_GROUP_CREATION': ( + 'oauth.enable_group_creation', + ENABLE_OAUTH_GROUP_CREATION, + ), + 'OAUTH_GROUP_DEFAULT_SHARE': ( + 'oauth.group_default_share', + OAUTH_GROUP_DEFAULT_SHARE, + ), + 'OAUTH_BLOCKED_GROUPS': ('oauth.blocked_groups', OAUTH_BLOCKED_GROUPS), + 'OAUTH_ROLES_CLAIM': ('oauth.roles_claim', OAUTH_ROLES_CLAIM), + 'OAUTH_SUB_CLAIM': ('oauth.sub_claim', OAUTH_SUB_CLAIM), + 'OAUTH_GROUPS_CLAIM': ('oauth.group_claim', OAUTH_GROUPS_CLAIM), + 'OAUTH_EMAIL_CLAIM': ('oauth.email_claim', OAUTH_EMAIL_CLAIM), + 'OAUTH_PICTURE_CLAIM': ('oauth.picture_claim', OAUTH_PICTURE_CLAIM), + 'OAUTH_USERNAME_CLAIM': ('oauth.username_claim', OAUTH_USERNAME_CLAIM), + 'OAUTH_ALLOWED_ROLES': ('oauth.allowed_roles', OAUTH_ALLOWED_ROLES), + 'OAUTH_ADMIN_ROLES': ('oauth.admin_roles', OAUTH_ADMIN_ROLES), + 'OAUTH_ALLOWED_DOMAINS': ('oauth.allowed_domains', OAUTH_ALLOWED_DOMAINS), + 'WEBHOOK_URL': ('webhook_url', WEBHOOK_URL), + 'JWT_EXPIRES_IN': ('auth.jwt_expiry', JWT_EXPIRES_IN), + 'OAUTH_UPDATE_PICTURE_ON_LOGIN': ( + 'oauth.update_picture_on_login', + OAUTH_UPDATE_PICTURE_ON_LOGIN, + ), + 'OAUTH_UPDATE_NAME_ON_LOGIN': ( + 'oauth.update_name_on_login', + OAUTH_UPDATE_NAME_ON_LOGIN, + ), + 'OAUTH_UPDATE_EMAIL_ON_LOGIN': ( + 'oauth.update_email_on_login', + OAUTH_UPDATE_EMAIL_ON_LOGIN, + ), + 'OAUTH_AUDIENCE': ('oauth.audience', OAUTH_AUDIENCE), +} + + +def _default_value(value): + return getattr(value, 'value', value) + + +async def get_oauth_runtime_config() -> SimpleNamespace: + keys = [key for key, _default in OAUTH_RUNTIME_CONFIG.values()] + stored = await Config.get_many(*keys) + values = { + name: stored.get(key, _default_value(default)) + for name, (key, default) in OAUTH_RUNTIME_CONFIG.items() + } + return SimpleNamespace(**values) # Conservative default when the provider omits both expires_in and expires_at. @@ -423,7 +464,8 @@ async def get_oauth_client_info_with_dynamic_client_registration( oauth_server_metadata = None oauth_server_metadata_url = None - redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + webui_url = await Config.get('webui.url') + redirect_base_url = (str(webui_url or request.base_url)).rstrip('/') oauth_client_metadata = OAuthClientMetadata( client_name='Open WebUI', @@ -549,7 +591,8 @@ async def get_oauth_client_info_with_static_credentials( oauth_server_metadata = None oauth_server_metadata_url = None - redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + webui_url = await Config.get('webui.url') + redirect_base_url = (str(webui_url or request.base_url)).rstrip('/') redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback' # Discover server metadata (authorization endpoint, token endpoint, scopes, etc.) @@ -636,7 +679,7 @@ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): 'client_secret': oauth_client_info.client_secret, 'client_kwargs': { 'follow_redirects': True, - **({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}), + **({'timeout': int(OAUTH_CLIENT_TIMEOUT)} if OAUTH_CLIENT_TIMEOUT else {}), **({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}), **( {'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method} @@ -668,7 +711,7 @@ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): } return self.clients[client_id] - def ensure_client_from_config(self, client_id): + async def ensure_client_from_config(self, client_id): """ Lazy-load an OAuth client from the current TOOL_SERVER_CONNECTIONS config if it hasn't been registered on this node yet. @@ -677,7 +720,7 @@ def ensure_client_from_config(self, client_id): return self.clients[client_id]['client'] try: - connections = getattr(self.app.state.config, 'TOOL_SERVER_CONNECTIONS', []) + connections = await Config.get('tool_server.connections', []) except Exception: connections = [] @@ -783,22 +826,22 @@ async def _preflight_authorization_url(self, client, client_info: OAuthClientInf return True - def get_client(self, client_id): + async def get_client(self, client_id): if client_id not in self.clients: - self.ensure_client_from_config(client_id) + await self.ensure_client_from_config(client_id) client = self.clients.get(client_id) return client['client'] if client else None - def get_client_info(self, client_id): + async def get_client_info(self, client_id): if client_id not in self.clients: - self.ensure_client_from_config(client_id) + await self.ensure_client_from_config(client_id) client = self.clients.get(client_id) return client['client_info'] if client else None - def get_server_metadata_url(self, client_id): - client = self.get_client(client_id) + async def get_server_metadata_url(self, client_id): + client = await self.get_client(client_id) if not client: return None @@ -881,6 +924,7 @@ async def _perform_token_refresh(self, session) -> dict: Returns: dict: New token data, or None if refresh failed """ + auth_config = await get_oauth_runtime_config() client_id = session.provider token_data = session.token @@ -889,14 +933,14 @@ async def _perform_token_refresh(self, session) -> dict: return None try: - client = self.get_client(client_id) + client = await self.get_client(client_id) if not client: log.error(f'No OAuth client found for provider {client_id}') return None token_endpoint = None async with aiohttp.ClientSession(trust_env=True) as session_http: - async with session_http.get(self.get_server_metadata_url(client_id)) as r: + async with session_http.get(await self.get_server_metadata_url(client_id)) as r: if r.status == 200: openid_data = await r.json() token_endpoint = openid_data.get('token_endpoint') @@ -913,7 +957,7 @@ async def _perform_token_refresh(self, session) -> dict: 'client_id': client.client_id, } # RFC 8707: include resource indicator so refreshed tokens retain correct audience - client_info = self.get_client_info(client_id) + client_info = await self.get_client_info(client_id) if client_info and client_info.resource: refresh_data['resource'] = client_info.resource @@ -924,7 +968,7 @@ async def _perform_token_refresh(self, session) -> dict: if ( hasattr(client, 'client_kwargs') and client.client_kwargs.get('scope') - and getattr(self.app.state.config, 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', False) + and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE ): refresh_data['scope'] = client.client_kwargs['scope'] @@ -957,13 +1001,13 @@ async def _perform_token_refresh(self, session) -> dict: return None async def handle_authorize(self, request, client_id: str) -> RedirectResponse: - client = self.get_client(client_id) or self.ensure_client_from_config(client_id) + client = await self.get_client(client_id) if client is None: raise HTTPException(404) - client_info = self.get_client_info(client_id) + client_info = await self.get_client_info(client_id) if client_info is None: - # ensure_client_from_config registers client_info too - client_info = self.get_client_info(client_id) + # get_client registers client_info too + client_info = await self.get_client_info(client_id) if client_info is None: raise HTTPException(404) @@ -976,13 +1020,13 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: return await client.authorize_redirect(request, redirect_uri_str, **kwargs) async def handle_callback(self, request, client_id: str, user_id: str, response): - client = self.get_client(client_id) or self.ensure_client_from_config(client_id) + client = await self.get_client(client_id) if client is None: raise HTTPException(404) error_message = None try: - client_info = self.get_client_info(client_id) + client_info = await self.get_client_info(client_id) # Note: Do NOT pass client_id/client_secret explicitly here. # The Authlib client already has these configured during add_client(). @@ -1035,7 +1079,8 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) exc_info=True, ) - redirect_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + webui_url = await Config.get('webui.url') + redirect_url = (str(webui_url or request.base_url)).rstrip('/') if error_message: log.debug(error_message) @@ -1202,7 +1247,7 @@ async def _perform_token_refresh(self, session) -> dict: if ( hasattr(client, 'client_kwargs') and client.client_kwargs.get('scope') - and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE + and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE ): refresh_data['scope'] = client.client_kwargs['scope'] @@ -1235,6 +1280,7 @@ async def _perform_token_refresh(self, session) -> dict: return None async def get_user_role(self, user, user_data): + auth_config = await get_oauth_runtime_config() user_count = await Users.get_num_users() if user and user_count == 1: # If the user is the only user, assign the role "admin" - actually repairs role for single user on login @@ -1246,16 +1292,16 @@ async def get_user_role(self, user, user_data): # default role here (not 'admin') — admin promotion happens # race-safely *after* insert via get_num_users() == 1. log.debug('First user bootstrap: using default role (admin promotion deferred to post-insert)') - return auth_manager_config.DEFAULT_USER_ROLE + return auth_config.DEFAULT_USER_ROLE - if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT: + if auth_config.ENABLE_OAUTH_ROLE_MANAGEMENT: log.debug('Running OAUTH Role management') - 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_claim = auth_config.OAUTH_ROLES_CLAIM + oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES + oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES oauth_roles = [] # Default/fallback role if no matching roles are found - role = auth_manager_config.DEFAULT_USER_ROLE + role = auth_config.DEFAULT_USER_ROLE # Next block extracts the roles from the user data, accepting nested claims of any depth if oauth_claim and oauth_allowed_roles and oauth_admin_roles: @@ -1313,7 +1359,7 @@ async def get_user_role(self, user, user_data): else: if not user: # If role management is disabled, use the default role for new users - role = auth_manager_config.DEFAULT_USER_ROLE + role = auth_config.DEFAULT_USER_ROLE else: # If role management is disabled, use the existing role for existing users role = user.role @@ -1321,11 +1367,12 @@ async def get_user_role(self, user, user_data): return role async def update_user_groups(self, user, user_data, default_permissions, db=None): + auth_config = await get_oauth_runtime_config() log.debug('Running OAUTH Group management') - oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM + oauth_claim = auth_config.OAUTH_GROUPS_CLAIM try: - blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS) + blocked_groups = json.loads(auth_config.OAUTH_BLOCKED_GROUPS) except Exception as e: log.exception(f'Error loading OAUTH_BLOCKED_GROUPS: {e}') blocked_groups = [] @@ -1353,7 +1400,7 @@ async def update_user_groups(self, user, user_data, default_permissions, db=None all_available_groups: list[GroupModel] = await Groups.get_all_groups(db=db) # Create groups if they don't exist and creation is enabled - if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: + if auth_config.ENABLE_OAUTH_GROUP_CREATION: log.debug('Checking for missing groups to create...') all_group_names = {g.name for g in all_available_groups} groups_created = False @@ -1370,7 +1417,7 @@ async def update_user_groups(self, user, user_data, default_permissions, db=None name=group_name, description=f"Group '{group_name}' created automatically via OAuth.", permissions=default_permissions, # Use default permissions from function args - data={'config': {'share': auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE}}, + data={'config': {'share': auth_config.OAUTH_GROUP_DEFAULT_SHARE}}, ) # Use determined creator ID (admin or fallback to current user) created_group = await Groups.insert_new_group(creator_id, new_group_form, db=db) @@ -1496,6 +1543,7 @@ async def _process_picture_url(self, picture_url: str, access_token: str = None) return '/user.png' async def handle_login(self, request, provider): + auth_config = await get_oauth_runtime_config() if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one @@ -1507,14 +1555,15 @@ async def handle_login(self, request, provider): ) kwargs = {} - if auth_manager_config.OAUTH_AUDIENCE: - kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE + if auth_config.OAUTH_AUDIENCE: + kwargs['audience'] = auth_config.OAUTH_AUDIENCE if OAUTH_AUTHORIZE_PARAMS: kwargs.update(OAUTH_AUTHORIZE_PARAMS) return await client.authorize_redirect(request, redirect_uri, **kwargs) async def handle_callback(self, request, provider, response, db=None): + auth_config = await get_oauth_runtime_config() if provider not in OAUTH_PROVIDERS: raise HTTPException(404) @@ -1568,8 +1617,8 @@ async def handle_callback(self, request, provider, response, db=None): id_token_claims = dict(user_data) if user_data else {} if ( (not user_data) - or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) - or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) + or (auth_config.OAUTH_EMAIL_CLAIM not in user_data) + or (auth_config.OAUTH_USERNAME_CLAIM not in user_data) ): user_data: UserInfo = await client.userinfo(token=token) # Merge back ID token claims that the userinfo endpoint doesn't @@ -1585,8 +1634,8 @@ async def handle_callback(self, request, provider, response, db=None): raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Extract the "sub" claim, using custom claim if configured - if auth_manager_config.OAUTH_SUB_CLAIM: - sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) + if auth_config.OAUTH_SUB_CLAIM: + sub = user_data.get(auth_config.OAUTH_SUB_CLAIM) else: # Fallback to the default sub claim if not configured sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) @@ -1600,7 +1649,7 @@ async def handle_callback(self, request, provider, response, db=None): } # Email extraction - email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM + email_claim = auth_config.OAUTH_EMAIL_CLAIM email = user_data.get(email_claim, '') # We currently mandate that email addresses are provided if not email: @@ -1642,8 +1691,8 @@ async def handle_callback(self, request, provider, response, db=None): email = email.lower() # If allowed domains are configured, check if the email domain is in the list if ( - '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + '*' not in auth_config.OAUTH_ALLOWED_DOMAINS + and email.split('@')[-1] not in auth_config.OAUTH_ALLOWED_DOMAINS ): log.warning(f'OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) @@ -1652,7 +1701,7 @@ async def handle_callback(self, request, provider, response, db=None): user = await Users.get_user_by_oauth_sub(provider, sub, db=db) if not user: # If the user does not exist, check if merging is enabled - if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: + if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: # Check if the user exists by email user = await Users.get_user_by_email(email, db=db) if user: @@ -1667,8 +1716,8 @@ async def handle_callback(self, request, provider, response, db=None): # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below user.role = determined_role - if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN: - username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM + if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: + username_claim = auth_config.OAUTH_USERNAME_CLAIM if username_claim: new_name = user_data.get(username_claim) if new_name and new_name != user.name: @@ -1676,8 +1725,8 @@ async def handle_callback(self, request, provider, response, db=None): user.name = new_name log.debug(f'Updated name for user {user.email}') - if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: - email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM + if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: + email_claim = auth_config.OAUTH_EMAIL_CLAIM if email_claim: new_email = user_data.get(email_claim) if new_email and new_email.lower() != user.email.lower(): @@ -1692,8 +1741,8 @@ async def handle_callback(self, request, provider, response, db=None): log.debug(f'Updated email for user {user.id}') # Update profile picture if enabled and different from current - if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: - picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_config.OAUTH_PICTURE_CLAIM if picture_claim: new_picture_url = user_data.get( picture_claim, @@ -1707,13 +1756,13 @@ async def handle_callback(self, request, provider, response, db=None): log.debug(f'Updated profile picture for user {user.email}') else: # If the user does not exist, check if signups are enabled - if auth_manager_config.ENABLE_OAUTH_SIGNUP: + if auth_config.ENABLE_OAUTH_SIGNUP: # Check if an existing user with the same email already exists existing_user = await Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) - picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + picture_claim = auth_config.OAUTH_PICTURE_CLAIM if picture_claim: picture_url = user_data.get( picture_claim, @@ -1722,7 +1771,7 @@ async def handle_callback(self, request, provider, response, db=None): picture_url = await self._process_picture_url(picture_url, token.get('access_token')) else: picture_url = '/user.png' - username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM + username_claim = auth_config.OAUTH_USERNAME_CLAIM name = user_data.get(username_claim) if not name: @@ -1749,10 +1798,10 @@ async def handle_callback(self, request, provider, response, db=None): await Users.update_user_role_by_id(user.id, 'admin', db=db) user = await Users.get_user_by_id(user.id, db=db) - if auth_manager_config.WEBHOOK_URL: + if auth_config.WEBHOOK_URL: await post_webhook( WEBUI_NAME, - auth_manager_config.WEBHOOK_URL, + auth_config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { 'action': 'signup', @@ -1761,7 +1810,8 @@ async def handle_callback(self, request, provider, response, db=None): }, ) - await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db) + default_group_id = await Config.get('ui.default_group_id') + await apply_default_group_assignment(default_group_id, user.id, db=db) else: raise HTTPException( @@ -1771,13 +1821,13 @@ async def handle_callback(self, request, provider, response, db=None): jwt_token = create_token( data={'id': user.id}, - expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), + expires_delta=parse_duration(auth_config.JWT_EXPIRES_IN), ) - if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT: + if auth_config.ENABLE_OAUTH_GROUP_MANAGEMENT: await self.update_user_groups( user=user, user_data=user_data, - default_permissions=request.app.state.config.USER_PERMISSIONS, + default_permissions=await Config.get('user.permissions'), db=db, ) @@ -1789,7 +1839,8 @@ async def handle_callback(self, request, provider, response, db=None): else ERROR_MESSAGES.DEFAULT('Error during OAuth process') ) - redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + webui_url = await Config.get('webui.url') + redirect_base_url = (str(webui_url or request.base_url)).rstrip('/') redirect_url = f'{redirect_base_url}/auth' if error_message: @@ -1799,7 +1850,7 @@ async def handle_callback(self, request, provider, response, db=None): response = RedirectResponse(url=redirect_url, headers=response.headers) # Compute cookie expiry from JWT lifetime - expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN) + expires_delta = parse_duration(auth_config.JWT_EXPIRES_IN) cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None # Set the cookie token diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 2bfa1940f94..c5ef34a2995 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -42,6 +42,7 @@ REDIS_KEY_PREFIX, ) from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.tools import Tools from open_webui.models.users import UserModel @@ -345,7 +346,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr continue tool_server_idx = tool_server_data.get('idx', 0) - connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + connections = await Config.get('tool_server.connections', []) if tool_server_idx >= len(connections): log.warning( f'Tool server index {tool_server_idx} out of range ' @@ -451,6 +452,16 @@ def is_builtin_tool_enabled(category: str) -> bool: # Helper to check user-level feature permission (admins always pass) user = extra_params.get('__user__', {}) + config = await Config.get_many( + 'rag.web.search.enable', + 'image_generation.enable', + 'images.edit.enable', + 'code_interpreter.enable', + 'notes.enable', + 'channels.enable', + 'automations.enable', + 'calendar.enable', + ) async def has_user_permission(feature_key: str) -> bool: if user.get('role') == 'admin': @@ -458,7 +469,7 @@ async def has_user_permission(feature_key: str) -> bool: return await has_permission( user.get('id', ''), f'features.{feature_key}', - request.app.state.config.USER_PERMISSIONS, + await Config.get('user.permissions'), ) # Time utilities - available for date calculations @@ -533,7 +544,7 @@ async def has_user_permission(feature_key: str) -> bool: # Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability if ( is_builtin_tool_enabled('web_search') - and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False) + and config.get('rag.web.search.enable') and get_model_capability('web_search') and features.get('web_search') and await has_user_permission('web_search') @@ -543,7 +554,7 @@ async def has_user_permission(feature_key: str) -> bool: # Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability if ( is_builtin_tool_enabled('image_generation') - and getattr(request.app.state.config, 'ENABLE_IMAGE_GENERATION', False) + and config.get('image_generation.enable') and get_model_capability('image_generation') and features.get('image_generation') and await has_user_permission('image_generation') @@ -551,7 +562,7 @@ async def has_user_permission(feature_key: str) -> bool: builtin_functions.append(generate_image) if ( is_builtin_tool_enabled('image_generation') - and getattr(request.app.state.config, 'ENABLE_IMAGE_EDIT', False) + and config.get('images.edit.enable') and get_model_capability('image_generation') and features.get('image_generation') and await has_user_permission('image_generation') @@ -561,7 +572,7 @@ async def has_user_permission(feature_key: str) -> bool: # Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability if ( is_builtin_tool_enabled('code_interpreter') - and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True) + and config.get('code_interpreter.enable') and get_model_capability('code_interpreter') and features.get('code_interpreter') and await has_user_permission('code_interpreter') @@ -571,7 +582,7 @@ async def has_user_permission(feature_key: str) -> bool: # Notes tools - search, view, create, and update user's notes if ( is_builtin_tool_enabled('notes') - and getattr(request.app.state.config, 'ENABLE_NOTES', False) + and config.get('notes.enable') and await has_user_permission('notes') ): builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) @@ -579,7 +590,7 @@ async def has_user_permission(feature_key: str) -> bool: # Channels tools - search channels and messages if ( is_builtin_tool_enabled('channels') - and getattr(request.app.state.config, 'ENABLE_CHANNELS', False) + and config.get('channels.enable') and await has_user_permission('channels') ): builtin_functions.extend( @@ -602,7 +613,7 @@ async def has_user_permission(feature_key: str) -> bool: # Automation tools - create and manage scheduled automations from chat if ( is_builtin_tool_enabled('automations') - and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False) + and config.get('automations.enable') and await has_user_permission('automations') ): builtin_functions.extend( @@ -612,7 +623,7 @@ async def has_user_permission(feature_key: str) -> bool: # Calendar tools - search/create/update/delete events if ( is_builtin_tool_enabled('calendar') - and getattr(request.app.state.config, 'ENABLE_CALENDAR', False) + and config.get('calendar.enable') and await has_user_permission('calendar') ): builtin_functions.extend( @@ -958,7 +969,7 @@ def convert_openapi_to_tool_payload(openapi_spec): async def set_tool_servers(request: Request): try: - request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) + request.app.state.TOOL_SERVERS = await get_tool_servers_data(await Config.get('tool_server.connections', [])) except Exception as e: log.error(f'Error fetching tool server data: {e}') request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or [] @@ -1055,7 +1066,7 @@ async def get_terminal_system_prompt( async def set_terminal_servers(request: Request): """Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS.""" - connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connections = await Config.get('terminal_server.connections', []) or [] # Build server configs compatible with get_tool_servers_data # Terminal connections store id/name at top level; translate to info dict @@ -1148,7 +1159,7 @@ async def get_terminal_tools( - Loads specs from cache - Builds callables that route through the terminal proxy """ - connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connections = await Config.get('terminal_server.connections', []) or [] connection = next((c for c in connections if c.get('id') == terminal_id), None) if connection is None: log.warning(f'Terminal server not found: {terminal_id}') diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 5368c860644..fcd5b1708c1 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -254,6 +254,61 @@ export const updateLdapServer = async (token: string = '', body: object) => { return res; }; +export const getOAuthConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateOAuthConfig = async (token: string, body: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const userSignIn = async (email: string, password: string) => { let error = null; diff --git a/src/lib/components/admin/Settings.svelte b/src/lib/components/admin/Settings.svelte index 6b7bfbf0b41..1927e996571 100644 --- a/src/lib/components/admin/Settings.svelte +++ b/src/lib/components/admin/Settings.svelte @@ -8,6 +8,7 @@ import { getBackendConfig } from '$lib/apis'; import Database from './Settings/Database.svelte'; + import Authentication from './Settings/Authentication.svelte'; import General from './Settings/General.svelte'; import Pipelines from './Settings/Pipelines.svelte'; import Audio from './Settings/Audio.svelte'; @@ -37,6 +38,7 @@ const tabFromPath = pathParts[pathParts.length - 1]; selectedTab = [ 'general', + 'authentication', 'connections', 'models', 'evaluations', @@ -94,6 +96,24 @@ 'channels' ] }, + { + id: 'authentication', + title: 'Authentication', + route: '/admin/settings/authentication', + keywords: [ + 'authentication', + 'auth', + 'login', + 'signup', + 'ldap', + 'oauth', + 'oidc', + 'sso', + 'roles', + 'groups', + 'identity' + ] + }, { id: 'connections', title: 'Connections', @@ -308,6 +328,7 @@
+ @@ -344,6 +365,19 @@ clip-rule="evenodd" /> + {:else if tab.id === 'authentication'} + + + {:else if tab.id === 'connections'} + {:else if selectedTab === 'authentication'} + {:else if selectedTab === 'connections'} { diff --git a/src/lib/components/admin/Settings/Authentication.svelte b/src/lib/components/admin/Settings/Authentication.svelte new file mode 100644 index 00000000000..2a7319eddb3 --- /dev/null +++ b/src/lib/components/admin/Settings/Authentication.svelte @@ -0,0 +1,776 @@ + + +
+
+ {#if adminConfig !== null} +
+
{$i18n.t('User Access')}
+ +
+ +
+
{$i18n.t('Default User Role')}
+
+ +
+
+ +
+
{$i18n.t('Default Group')}
+
+ +
+
+ +
+
{$i18n.t('Enable New Sign Ups')}
+ + +
+ +
+
{$i18n.t('Enable API Keys')}
+ + +
+ + {#if adminConfig?.ENABLE_API_KEYS} +
+
+ {$i18n.t('API Key Endpoint Restrictions')} +
+ + +
+ + {#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} +
+ {/if} + {/if} + +
+
+
{$i18n.t('JWT Expiration')}
+
+ +
+ +
+ +
+ {$i18n.t('Valid time units:')} + {$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")} +
+ + {#if adminConfig.JWT_EXPIRES_IN === '-1'} +
+ +
+ {/if} +
+
{$i18n.t('Pending Accounts')}
+ +
+ +
+
+ {$i18n.t('Show Admin Details in Account Pending Overlay')} +
+ + +
+ + {#if adminConfig.SHOW_ADMIN_DETAILS} +
+
+
{$i18n.t('Admin Contact Email')}
+
+ +
+ +
+
+ {/if} + +
+
+ {$i18n.t('Pending User Overlay Title')} +
+ +
+
+ +
+
+ + +
+ +
{/if}
From a0c2ec3d2cf8d696ede479211330eec2da360d39 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 22 Jun 2026 17:02:14 +0200 Subject: [PATCH 092/346] refac --- src/lib/apis/terminal/index.ts | 20 ++++++- src/lib/components/chat/FileNav.svelte | 81 +++++++++++++++++++++----- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 69ee2c5a0a6..da491043efa 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -15,6 +15,17 @@ export type TerminalFeatures = { terminal?: boolean; }; +export type TerminalFileRoot = { + path: string; + label: string; +}; + +export type TerminalCwd = { + cwd: string | null; + home?: string; + root?: TerminalFileRoot; +}; + import { WEBUI_API_BASE_URL } from '$lib/constants'; export type TerminalServer = { @@ -49,14 +60,19 @@ export const getCwd = async ( baseUrl: string, apiKey: string, sessionId?: string -): Promise => { +): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; const headers: Record = { Authorization: `Bearer ${apiKey}` }; if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { headers }).catch(() => null); if (!res || !res.ok) return null; const json = await res.json().catch(() => null); - return json?.cwd ?? null; + if (!json) return null; + return { + cwd: json?.cwd ?? null, + home: json?.home, + root: json?.root + }; }; export const listFiles = async ( diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index c0043a2409f..61a40318fa7 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -25,7 +25,9 @@ deleteEntry, moveEntry, setCwd, - type FileEntry + type FileEntry, + type TerminalFileRoot, + type TerminalCwd } from '$lib/apis/terminal'; import { isCodeFile } from '$lib/utils/codeHighlight'; import Folder from '../icons/Folder.svelte'; @@ -88,6 +90,7 @@ // ── Directory state ────────────────────────────────────────────────── let currentPath = savedPath; + let fileRoot: TerminalFileRoot | null = null; let entries: FileEntry[] = []; let loading = false; let error: string | null = null; @@ -280,11 +283,8 @@ terminalEnabled = config?.features?.terminal !== false; } - const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); - const cwd = rawCwd ? normalizePath(rawCwd) : null; - const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/'; - savedPath = dir; - loadDir(dir); + savedPath = applyCwd(await getCwd(terminal.url, terminal.key, chatId ?? undefined)); + loadDir(savedPath); })(); } } @@ -305,7 +305,52 @@ /** Normalize Windows backslashes to forward slashes. */ const normalizePath = (p: string) => p.replace(/\\/g, '/'); + const asDirectoryPath = (path: string) => { + const normalized = normalizePath(path || '/'); + return normalized.endsWith('/') ? normalized : `${normalized}/`; + }; + + const setFileRoot = (root?: TerminalFileRoot) => { + fileRoot = root?.path + ? { + path: asDirectoryPath(root.path), + label: root.label || 'Home' + } + : null; + }; + + const isInsideFileRoot = (path: string) => { + if (!fileRoot) return true; + const directory = asDirectoryPath(path); + return directory === fileRoot.path || directory.startsWith(fileRoot.path); + }; + + const clampToFileRoot = (path: string) => { + if (!fileRoot) return asDirectoryPath(path); + return isInsideFileRoot(path) ? asDirectoryPath(path) : fileRoot.path; + }; + + const applyCwd = (cwd: TerminalCwd | null) => { + setFileRoot(cwd?.root); + const path = cwd?.cwd ? asDirectoryPath(cwd.cwd) : (fileRoot?.path ?? '/'); + return clampToFileRoot(path); + }; + const buildBreadcrumbs = (path: string) => { + if (fileRoot) { + const directory = clampToFileRoot(path); + const relative = directory === fileRoot.path ? '' : directory.slice(fileRoot.path.length); + const parts = relative.split('/').filter(Boolean); + return parts.reduce( + (acc, part) => { + const prev = acc[acc.length - 1]; + acc.push({ label: part, path: `${prev.path}${part}/` }); + return acc; + }, + [{ label: fileRoot.label, path: fileRoot.path }] + ); + } + const parts = path.split('/').filter(Boolean); const isDrive = /^[A-Za-z]:$/.test(parts[0] ?? ''); const root = isDrive ? { label: parts[0], path: `${parts[0]}/` } : { label: '/', path: '/' }; @@ -348,6 +393,7 @@ const loadDir = async (path: string) => { const terminal = selectedTerminal; if (!terminal) return; + const directory = clampToFileRoot(path); loading = true; error = null; @@ -355,15 +401,15 @@ previewPort = null; clearFilePreview(); clearSelection(); - currentPath = path; - savedPath = path; - pushNavHistory(path); + currentPath = directory; + savedPath = directory; + pushNavHistory(directory); - const result = await listFiles(terminal.url, terminal.key, path, chatId ?? undefined); + const result = await listFiles(terminal.url, terminal.key, directory, chatId ?? undefined); loading = false; // Set working directory on the terminal server (fire-and-forget) - setCwd(terminal.url, terminal.key, path, chatId ?? undefined); + setCwd(terminal.url, terminal.key, directory, chatId ?? undefined); if (result === null) { error = @@ -788,6 +834,10 @@ handledDisplayFile = true; showFileNavPath.set(null); filePath = normalizePath(filePath); + if (!isInsideFileRoot(filePath)) { + await loadDir(fileRoot?.path ?? '/'); + return; + } const lastSlash = filePath.lastIndexOf('/'); const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/'; @@ -810,6 +860,10 @@ if (!filePath || !selectedTerminal) return; showFileNavDir.set(null); filePath = normalizePath(filePath); + if (!isInsideFileRoot(filePath)) { + await loadDir(fileRoot?.path ?? '/'); + return; + } const lastSlash = filePath.lastIndexOf('/'); const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/'; @@ -835,10 +889,9 @@ if (chatId || savedPath === '/') { // Fetch session-specific cwd from the server (or global default for new chats) - const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); - const cwd = rawCwd ? normalizePath(rawCwd) : null; - if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/'; + savedPath = applyCwd(await getCwd(terminal.url, terminal.key, chatId ?? undefined)); } + savedPath = clampToFileRoot(savedPath); loadDir(savedPath); } From 91762ed8070ae213161153ba227a92b861b778e8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 23 Jun 2026 00:25:21 +0200 Subject: [PATCH 093/346] refac --- backend/open_webui/main.py | 2 +- backend/open_webui/routers/functions.py | 10 ++-- backend/open_webui/routers/images.py | 5 +- backend/open_webui/routers/models.py | 6 +- backend/open_webui/routers/tools.py | 37 ++++--------- backend/open_webui/utils/models.py | 9 +-- backend/open_webui/utils/plugin.py | 74 +++++++++++++++---------- backend/open_webui/utils/tools.py | 12 ++-- 8 files changed, 77 insertions(+), 78 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 7c8422a2ff4..25dda87f78f 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -839,7 +839,7 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v models.append(model) - model_order_list = await Config.get('models.order_list') + model_order_list = await Config.get('ui.model_order_list') if model_order_list: model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)} # Sort models by order list priority, with fallback for those not in the list diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 58ec93657e9..d6eab3e0bd5 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -22,6 +22,7 @@ ) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.plugin import ( + get_functions_cache, get_function_module_from_cache, load_function_module_by_id, replace_imports, @@ -205,7 +206,7 @@ async def create_new_function( ) form_data.meta.manifest = frontmatter - FUNCTIONS = request.app.state.FUNCTIONS + FUNCTIONS = get_functions_cache(request) FUNCTIONS[form_data.id] = function_module function = await Functions.insert_new_function(user.id, function_type, form_data, db=db) @@ -322,7 +323,7 @@ async def update_function_by_id( function_module, function_type, frontmatter = await load_function_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter - FUNCTIONS = request.app.state.FUNCTIONS + FUNCTIONS = get_functions_cache(request) FUNCTIONS[id] = function_module updated = {**form_data.model_dump(exclude={'id'}), 'type': function_type} @@ -363,9 +364,8 @@ async def delete_function_by_id( result = await Functions.delete_function_by_id(id, db=db) if result: - FUNCTIONS = request.app.state.FUNCTIONS - if id in FUNCTIONS: - del FUNCTIONS[id] + FUNCTIONS = get_functions_cache(request) + FUNCTIONS.pop(id, None) return result diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 951fd905e7a..17f1acb7e5e 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -773,14 +773,15 @@ async def edit_images(request: Request, form_data: EditImageForm, user=Depends(g # global image-edit switch and the per-user image-generation permission. The internal # callers (edit_image tool, chat middleware) gate themselves and call image_edits() # directly, so they are unaffected by this wrapper. - if not request.app.state.config.ENABLE_IMAGE_EDIT: + image_config = await get_image_config() + if not image_config.ENABLE_IMAGE_EDIT: raise HTTPException( status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) if user.role != 'admin' and not await has_permission( - user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS + user.id, 'features.image_generation', image_config.USER_PERMISSIONS ): raise HTTPException( status_code=403, diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 95b9e46bfed..ed4d221bc69 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -532,11 +532,7 @@ async def get_model_profile_image( # Fallback: check arena models stored in config (not in the DB) if not profile_image_url: - arena_models = getattr( - getattr(request.app.state, 'config', None), - 'EVALUATION_ARENA_MODELS', - [], - ) + arena_models = await Config.get('evaluation.arena.models', []) or [] for arena_model in arena_models: if arena_model.get('id') == id: profile_image_url = arena_model.get('meta', {}).get('profile_image_url') diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index abd49dd64fa..0c3ee91c60b 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -31,6 +31,7 @@ ) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.plugin import ( + get_tools_cache, get_tool_module_from_cache, load_tool_module_by_id, replace_imports, @@ -70,8 +71,9 @@ async def get_tools( tools = [] # Local Tools + tools_cache = get_tools_cache(request) for tool in await Tools.get_tools(defer_content=True, db=db): - tool_module = request.app.state.TOOLS.get(tool.id) if hasattr(request.app.state, 'TOOLS') else None + tool_module = tools_cache.get(tool.id) tools.append( ToolUserResponse( **{ @@ -368,7 +370,7 @@ async def create_new_tools( tool_module, frontmatter = await load_tool_module_by_id(form_data.id, content=form_data.content) form_data.meta.manifest = frontmatter - TOOLS = request.app.state.TOOLS + TOOLS = get_tools_cache(request) TOOLS[form_data.id] = tool_module specs = get_tool_specs(TOOLS[form_data.id]) @@ -498,7 +500,7 @@ async def update_tools_by_id( tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter - TOOLS = request.app.state.TOOLS + TOOLS = get_tools_cache(request) TOOLS[id] = tool_module specs = get_tool_specs(TOOLS[id]) @@ -624,9 +626,8 @@ async def delete_tools_by_id( result = await Tools.delete_tool_by_id(id, db=db) if result: - TOOLS = request.app.state.TOOLS - if id in TOOLS: - del TOOLS[id] + TOOLS = get_tools_cache(request) + TOOLS.pop(id, None) return result @@ -708,11 +709,7 @@ async def get_tools_valves_spec_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = await load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module + tools_module, _ = await get_tool_module_from_cache(request, id) if hasattr(tools_module, 'Valves'): Valves = tools_module.Valves @@ -759,11 +756,7 @@ async def update_tools_valves_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = await load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module + tools_module, _ = await get_tool_module_from_cache(request, id) if not hasattr(tools_module, 'Valves'): raise HTTPException( @@ -858,11 +851,7 @@ async def get_tools_user_valves_spec_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = await load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module + tools_module, _ = await get_tool_module_from_cache(request, id) if hasattr(tools_module, 'UserValves'): UserValves = tools_module.UserValves @@ -904,11 +893,7 @@ async def update_tools_user_valves_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = await load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module + tools_module, _ = await get_tool_module_from_cache(request, id) if hasattr(tools_module, 'UserValves'): UserValves = tools_module.UserValves diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index f770249f89d..ca8179ea377 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -21,8 +21,8 @@ from open_webui.socket.utils import RedisDict from open_webui.utils.access_control import has_access, has_base_model_access from open_webui.utils.plugin import ( + get_functions_cache, get_function_module_from_cache, - load_function_module_by_id, ) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -322,10 +322,11 @@ def get_filter_items_from_module(function, module): # Batch-fetch all function valves in one query to avoid N+1 DB hits # inside get_action_priority (previously called per action × per model). all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids)) + functions_cache = get_functions_cache(request) def get_action_priority(action_id): try: - function_module = request.app.state.FUNCTIONS.get(action_id) + function_module = functions_cache.get(action_id) if function_module and hasattr(function_module, 'Valves'): valves_db = all_function_valves.get(action_id) valves = function_module.Valves(**(valves_db if valves_db else {})) @@ -355,7 +356,7 @@ def get_action_priority(action_id): log.info(f'Action not found: {action_id}') continue - function_module = request.app.state.FUNCTIONS.get(action_id) + function_module = functions_cache.get(action_id) if function_module is None: log.info(f'Failed to load action module: {action_id}') continue @@ -368,7 +369,7 @@ def get_action_priority(action_id): log.info(f'Filter not found: {filter_id}') continue - function_module = request.app.state.FUNCTIONS.get(filter_id) + function_module = functions_cache.get(filter_id) if function_module is None: log.info(f'Failed to load filter module: {filter_id}') continue diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index d7aa0a0a39f..60878240856 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -298,7 +298,33 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non os.unlink(temp_file.name) +def _state_cache(request, name: str) -> dict: + if not hasattr(request.app.state, name): + setattr(request.app.state, name, {}) + return getattr(request.app.state, name) + + +def get_tools_cache(request) -> dict: + return _state_cache(request, 'TOOLS') + + +def get_tool_contents_cache(request) -> dict: + return _state_cache(request, 'TOOL_CONTENTS') + + +def get_functions_cache(request) -> dict: + return _state_cache(request, 'FUNCTIONS') + + +def get_function_contents_cache(request) -> dict: + return _state_cache(request, 'FUNCTION_CONTENTS') + + async def get_tool_module_from_cache(request, tool_id, load_from_db=True): + tools_cache = get_tools_cache(request) + tool_contents_cache = get_tool_contents_cache(request) + content = None + if load_from_db: # Always load from the database by default tool = await Tools.get_tool_by_id(tool_id) @@ -312,27 +338,19 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True): # Update the tool content in the database await Tools.update_tool_by_id(tool_id, {'content': content}) - if (hasattr(request.app.state, 'TOOL_CONTENTS') and tool_id in request.app.state.TOOL_CONTENTS) and ( - hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS - ): - if request.app.state.TOOL_CONTENTS[tool_id] == content: - return request.app.state.TOOLS[tool_id], None + if tool_id in tool_contents_cache and tool_id in tools_cache: + if tool_contents_cache[tool_id] == content: + return tools_cache[tool_id], None tool_module, frontmatter = await load_tool_module_by_id(tool_id, content) else: - if hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS: - return request.app.state.TOOLS[tool_id], None + if tool_id in tools_cache: + return tools_cache[tool_id], None tool_module, frontmatter = await load_tool_module_by_id(tool_id) - if not hasattr(request.app.state, 'TOOLS'): - request.app.state.TOOLS = {} - - if not hasattr(request.app.state, 'TOOL_CONTENTS'): - request.app.state.TOOL_CONTENTS = {} - - request.app.state.TOOLS[tool_id] = tool_module - request.app.state.TOOL_CONTENTS[tool_id] = content + tools_cache[tool_id] = tool_module + tool_contents_cache[tool_id] = content return tool_module, frontmatter @@ -340,6 +358,10 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True): async def get_function_module_from_cache( request, function_id, function: FunctionModel | None = None, load_from_db=True ): + functions_cache = get_functions_cache(request) + function_contents_cache = get_function_contents_cache(request) + content = None + if load_from_db: # Always load from the database by default # This is useful for hooks like "inlet" or "outlet" where the content might change @@ -357,30 +379,22 @@ async def get_function_module_from_cache( # Update the function content in the database await Functions.update_function_by_id(function_id, {'content': content}) - if ( - hasattr(request.app.state, 'FUNCTION_CONTENTS') and function_id in request.app.state.FUNCTION_CONTENTS - ) and (hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS): - if request.app.state.FUNCTION_CONTENTS[function_id] == content: - return request.app.state.FUNCTIONS[function_id], None, None + if function_id in function_contents_cache and function_id in functions_cache: + if function_contents_cache[function_id] == content: + return functions_cache[function_id], None, None function_module, function_type, frontmatter = await load_function_module_by_id(function_id, content) else: # Load from cache (e.g. "stream" hook) # This is useful for performance reasons - if hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS: - return request.app.state.FUNCTIONS[function_id], None, None + if function_id in functions_cache: + return functions_cache[function_id], None, None function_module, function_type, frontmatter = await load_function_module_by_id(function_id) - if not hasattr(request.app.state, 'FUNCTIONS'): - request.app.state.FUNCTIONS = {} - - if not hasattr(request.app.state, 'FUNCTION_CONTENTS'): - request.app.state.FUNCTION_CONTENTS = {} - - request.app.state.FUNCTIONS[function_id] = function_module - request.app.state.FUNCTION_CONTENTS[function_id] = content + functions_cache[function_id] = function_module + function_contents_cache[function_id] = content return function_module, function_type, frontmatter diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index c5ef34a2995..931d4288e50 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -95,7 +95,7 @@ from open_webui.utils.access_control import has_access, has_connection_access, has_permission from open_webui.utils.headers import get_custom_headers, include_user_info_headers from open_webui.utils.misc import is_string_allowed -from open_webui.utils.plugin import load_tool_module_by_id +from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo @@ -250,11 +250,13 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr log.warning(f'Access denied to tool {tool_id} for user {user.id}') continue - module = request.app.state.TOOLS.get(tool_id) - if module is None or request.app.state.TOOL_CONTENTS.get(tool_id) != tool.content: + tools_cache = get_tools_cache(request) + tool_contents_cache = get_tool_contents_cache(request) + module = tools_cache.get(tool_id) + if module is None or tool_contents_cache.get(tool_id) != tool.content: module, _ = await load_tool_module_by_id(tool_id, content=tool.content) - request.app.state.TOOLS[tool_id] = module - request.app.state.TOOL_CONTENTS[tool_id] = tool.content + tools_cache[tool_id] = module + tool_contents_cache[tool_id] = tool.content __user__ = { **extra_params['__user__'], From b1c2536ed2f8639efade04618018e6de9b332df2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 23 Jun 2026 23:13:28 +0200 Subject: [PATCH 094/346] refac --- backend/open_webui/retrieval/loaders/external_document.py | 8 +++++++- backend/open_webui/retrieval/loaders/main.py | 7 +++++++ backend/open_webui/utils/headers.py | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index ddafc3124bc..2dd70dbd4b1 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -6,7 +6,7 @@ import requests from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import get_custom_headers, include_user_info_headers log = logging.getLogger(__name__) @@ -19,6 +19,8 @@ def __init__( api_key: str, mime_type=None, user=None, + headers=None, + metadata=None, **kwargs, ) -> None: self.url = url @@ -28,6 +30,8 @@ def __init__( self.mime_type = mime_type self.user = user + self.headers = headers + self.metadata = metadata def load(self) -> List[Document]: with open(self.file_path, 'rb') as f: @@ -45,6 +49,8 @@ def load(self) -> List[Document]: except Exception: pass + headers.update(get_custom_headers(self.headers, self.user, self.metadata)) + if self.user is not None: headers = include_user_info_headers(headers, self.user) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index ee4166d1206..7841ee272bc 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -229,6 +229,7 @@ class Loader: def __init__(self, engine: str = '', **kwargs): self.engine = engine self.user = kwargs.get('user', None) + self.metadata = kwargs.get('metadata', {}) self.kwargs = kwargs def load(self, filename: str, file_content_type: str, file_path: str) -> list[Document]: @@ -404,6 +405,12 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'), mime_type=file_content_type, user=self.user, + headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), + metadata={ + **self.metadata, + 'file_name': filename, + 'file_content_type': file_content_type, + }, ) elif self.engine == 'tika' and self.kwargs.get('TIKA_SERVER_URL'): if self._is_text_file(file_ext, file_content_type): diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index cb77636ca4d..09095e2557b 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -75,6 +75,9 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) - '{{MESSAGE_ID}}': metadata.get('message_id', '') or '', '{{USER_MESSAGE_ID}}': user_message_id or '', '{{USER_MESSAGE_PARENT_ID}}': user_message_parent_id or '', + '{{FILE_ID}}': metadata.get('file_id', '') or '', + '{{FILE_NAME}}': metadata.get('file_name', '') or '', + '{{FILE_CONTENT_TYPE}}': metadata.get('file_content_type', '') or '', '{{TASK}}': metadata.get('task', '') or '', '{{USER_ID}}': (user.id if user else '') or '', '{{USER_NAME}}': (user.name if user else '') or '', From 15c7e374384488effc3d6059d09b3a8aa79c618d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 23 Jun 2026 23:13:32 +0200 Subject: [PATCH 095/346] refac --- backend/open_webui/config.py | 11 + backend/open_webui/models/knowledge.py | 30 + backend/open_webui/retrieval/external.py | 378 ++++++++ backend/open_webui/retrieval/utils.py | 109 ++- backend/open_webui/routers/knowledge.py | 619 +++++++++++- backend/open_webui/routers/retrieval.py | 14 + backend/open_webui/tools/builtin.py | 56 +- src/lib/apis/knowledge/index.ts | 302 +++++- src/lib/apis/retrieval/index.ts | 1 + src/lib/components/admin/Settings.svelte | 16 +- .../admin/Settings/Documents.svelte | 111 ++- .../admin/Settings/ExternalKnowledge.svelte | 883 ++++++++++++++++++ .../admin/Settings/Integrations.svelte | 73 +- src/lib/components/workspace/Knowledge.svelte | 85 +- .../workspace/Knowledge/KnowledgeBase.svelte | 583 +++++++----- 15 files changed, 2903 insertions(+), 368 deletions(-) create mode 100644 backend/open_webui/retrieval/external.py create mode 100644 src/lib/components/admin/Settings/ExternalKnowledge.svelte diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index ba4a3785483..4f6ca7af51a 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -857,6 +857,16 @@ def reachable(host: str, port: int) -> bool: EXTERNAL_DOCUMENT_LOADER_API_KEY = os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY', '') +external_document_loader_headers = os.getenv('EXTERNAL_DOCUMENT_LOADER_HEADERS', '') +try: + external_document_loader_headers = json.loads(external_document_loader_headers) +except json.JSONDecodeError: + external_document_loader_headers = {} +if not isinstance(external_document_loader_headers, dict): + external_document_loader_headers = {} + +EXTERNAL_DOCUMENT_LOADER_HEADERS = external_document_loader_headers + TIKA_SERVER_URL = os.getenv('TIKA_SERVER_URL', 'http://tika:9998') DOCLING_SERVER_URL = os.getenv('DOCLING_SERVER_URL', 'http://docling:5001') @@ -2652,6 +2662,7 @@ def feishu_oauth_register(oauth: OAuth): 'rag.mineru_file_extensions': MINERU_FILE_EXTENSIONS, 'rag.external_document_loader_url': EXTERNAL_DOCUMENT_LOADER_URL, 'rag.external_document_loader_api_key': EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'rag.external_document_loader_headers': EXTERNAL_DOCUMENT_LOADER_HEADERS, 'rag.tika_server_url': TIKA_SERVER_URL, 'rag.docling_server_url': DOCLING_SERVER_URL, 'rag.docling_api_key': DOCLING_API_KEY, diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 84cf4b7ae8d..f0ca77a6e71 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -286,6 +286,17 @@ async def search_knowledge_bases( elif view_option == 'shared': stmt = stmt.filter(Knowledge.user_id != user_id) + source = filter.get('source') + if source == 'external': + stmt = stmt.filter(Knowledge.meta['source'].as_string() == 'external') + elif source == 'local': + stmt = stmt.filter( + or_( + Knowledge.meta.is_(None), + Knowledge.meta['source'].as_string() != 'external', + ) + ) + stmt = AccessGrants.has_permission_filter( db=db, query=stmt, @@ -765,6 +776,25 @@ async def update_knowledge_data_by_id( log.exception(e) return None + async def update_knowledge_meta_by_id( + self, id: str, meta: dict, db: Optional[AsyncSession] = None + ) -> Optional[KnowledgeModel]: + try: + async with get_async_db_context(db) as db: + await db.execute( + update(Knowledge) + .filter_by(id=id) + .values( + meta=meta, + updated_at=int(time.time()), + ) + ) + await db.commit() + return await self.get_knowledge_by_id(id=id, db=db) + except Exception as e: + log.exception(e) + return None + async def delete_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/retrieval/external.py b/backend/open_webui/retrieval/external.py new file mode 100644 index 00000000000..802132b0bab --- /dev/null +++ b/backend/open_webui/retrieval/external.py @@ -0,0 +1,378 @@ +import asyncio +import logging +import re +import time +from typing import Any, Optional + +from open_webui.models.config import Config +from open_webui.models.knowledge import KnowledgeModel + +log = logging.getLogger(__name__) + +EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections' +IDENTIFIER_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +async def _get_external_connection(connection_id: str) -> Optional[dict]: + connections = await Config.get(EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY, []) or [] + return next((connection for connection in connections if connection.get('id') == connection_id), None) + + +def _get_path(data: Any, path: Optional[str], default=None): + if not path: + return default + value = data + for part in path.split('.'): + if isinstance(value, dict): + value = value.get(part, default) + else: + return default + return value + + +def _normalize_result(result: dict, mapping: dict, knowledge: KnowledgeModel, distance: Optional[float] = None) -> dict: + content = _get_path(result, mapping.get('content_field', 'content'), '') + title = _get_path(result, mapping.get('title_field', 'title'), None) + source = _get_path(result, mapping.get('source_field', 'source'), None) + url = _get_path(result, mapping.get('url_field', 'url'), None) + document_id = _get_path(result, mapping.get('document_id_field', 'document_id'), None) + page = _get_path(result, mapping.get('page_field', 'page'), None) + metadata = _get_path(result, mapping.get('metadata_field', 'metadata'), {}) or {} + score = _get_path(result, mapping.get('score_field', 'score'), distance) + + if not isinstance(metadata, dict): + metadata = {'external_metadata': metadata} + + source_name = source or title or metadata.get('source') or metadata.get('name') or knowledge.name + metadata.update( + { + 'name': title or source_name, + 'source': source_name, + 'url': url, + 'file_id': document_id or f'external-{knowledge.id}', + 'knowledge_id': knowledge.id, + 'knowledge_name': knowledge.name, + 'external': True, + } + ) + if page is not None: + metadata['page'] = page + if document_id is not None: + metadata['document_id'] = document_id + + return { + 'content': content, + 'metadata': metadata, + 'distance': score, + } + + +def _source_config(knowledge: KnowledgeModel) -> dict: + external = (knowledge.meta or {}).get('external', {}) + source = external.get('source') or {} + return source.get('config') or {} + + +def _root_field(path: Optional[str]) -> Optional[str]: + if not path: + return None + return path.split('.')[0] + + +def _safe_identifier(value: str, label: str) -> str: + if not value or not IDENTIFIER_RE.match(value): + raise RuntimeError(f'Invalid {label}') + return value + + +async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]: + try: + from qdrant_client import QdrantClient + except ImportError as exc: + raise RuntimeError('qdrant-client is not installed') from exc + + if not embedding_function: + raise RuntimeError('Embedding function is not configured') + + config = connection.get('config') or {} + external = (knowledge.meta or {}).get('external', {}) + source = external.get('source') or {} + collection_name = source.get('name') + if not collection_name: + raise RuntimeError('External source collection is not configured') + source_config = _source_config(knowledge) + vector_field = source_config.get('vector_field') or None + + vector = await embedding_function(query) + + def _search(): + client = QdrantClient( + url=connection.get('endpoint'), + api_key=(auth_config or {}).get('api_key'), + timeout=config.get('timeout') or 30, + ) + return client.query_points( + collection_name=collection_name, + query=vector, + using=vector_field, + limit=count, + ) + + response = await asyncio.to_thread(_search) + mapping = { + 'content_field': source_config.get('content_field') or 'payload.text', + 'metadata_field': source_config.get('metadata_field') or 'payload.metadata', + 'document_id_field': source_config.get('document_id_field') or 'id', + 'score_field': 'score', + } + + normalized = [] + for point in response.points: + normalized.append(_normalize_result(point.model_dump(), mapping, knowledge, distance=point.score)) + return normalized + + +async def _retrieve_milvus(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]: + try: + from pymilvus import MilvusClient + except ImportError as exc: + raise RuntimeError('pymilvus is not installed') from exc + + if not embedding_function: + raise RuntimeError('Embedding function is not configured') + + config = connection.get('config') or {} + external = (knowledge.meta or {}).get('external', {}) + source = external.get('source') or {} + collection_name = source.get('name') + if not collection_name: + raise RuntimeError('Milvus collection is not configured') + source_config = _source_config(knowledge) + vector_field = source_config.get('vector_field') or 'vector' + content_field = source_config.get('content_field') or 'data.text' + metadata_field = source_config.get('metadata_field') or 'metadata' + + vector = await embedding_function(query) + + def _search(): + client_kwargs = { + 'uri': connection.get('endpoint'), + } + token = (auth_config or {}).get('api_key') or (auth_config or {}).get('token') + if token: + client_kwargs['token'] = token + if config.get('db_name'): + client_kwargs['db_name'] = config.get('db_name') + + client = MilvusClient(**client_kwargs) + output_fields = { + field + for field in ( + _root_field(content_field), + _root_field(metadata_field), + _root_field(source_config.get('document_id_field')), + ) + if field and field != vector_field + } + kwargs = { + 'collection_name': collection_name, + 'data': [vector], + 'anns_field': vector_field, + 'limit': count, + 'output_fields': list(output_fields), + } + return client.search(**kwargs) + + response = await asyncio.to_thread(_search) + mapping = { + 'content_field': content_field, + 'metadata_field': metadata_field, + 'document_id_field': source_config.get('document_id_field') or 'id', + 'score_field': 'distance', + } + + normalized = [] + for hit in (response[0] if response else []): + item = dict(hit) + entity = item.get('entity') or {} + result = { + **entity, + 'id': item.get('id') or entity.get('id'), + 'distance': item.get('distance'), + } + normalized.append(_normalize_result(result, mapping, knowledge, distance=item.get('distance'))) + return normalized + + +async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]: + try: + import psycopg + from pgvector.psycopg import register_vector + from psycopg.rows import dict_row + except ImportError as exc: + raise RuntimeError('psycopg and pgvector are required for pgvector retrieval') from exc + + if not embedding_function: + raise RuntimeError('Embedding function is not configured') + + config = connection.get('config') or {} + external = (knowledge.meta or {}).get('external', {}) + source = external.get('source') or {} + collection_name = source.get('name') + if not collection_name: + raise RuntimeError('pgvector collection is not configured') + source_config = _source_config(knowledge) + table_name = source_config.get('table_name') or 'document_chunk' + collection_field = source_config.get('collection_field') or 'collection_name' + content_field = source_config.get('content_field') or 'text' + vector_field = source_config.get('vector_field') or 'vector' + metadata_field = source_config.get('metadata_field') or 'vmetadata' + document_id_field = source_config.get('document_id_field') or 'id' + + vector = await embedding_function(query) + + def _search(): + from psycopg import sql + + table_identifier = sql.SQL('.').join( + sql.Identifier(_safe_identifier(part, 'table name')) for part in table_name.split('.') + ) + collection_identifier = sql.Identifier(_safe_identifier(collection_field, 'collection field')) + content_identifier = sql.Identifier(_safe_identifier(content_field, 'content field')) + vector_identifier = sql.Identifier(_safe_identifier(vector_field, 'vector field')) + document_id_identifier = sql.Identifier(_safe_identifier(document_id_field, 'document id field')) + metadata_sql = ( + sql.Identifier(_safe_identifier(metadata_field, 'metadata field')) + if metadata_field + else sql.SQL("'{}'::jsonb") + ) + + with psycopg.connect( + connection.get('endpoint'), + row_factory=dict_row, + connect_timeout=config.get('timeout') or 30, + ) as conn: + register_vector(conn) + with conn.cursor() as cur: + cur.execute( + sql.SQL( + """ + SELECT {document_id} AS id, + {content} AS content, + {metadata} AS metadata, + {vector_column} <=> %s AS distance + FROM {table_name} + WHERE {collection} = %s + ORDER BY distance ASC + LIMIT %s + """ + ).format( + document_id=document_id_identifier, + content=content_identifier, + metadata=metadata_sql, + vector_column=vector_identifier, + table_name=table_identifier, + collection=collection_identifier, + ), + (vector, collection_name, count), + ) + return cur.fetchall() + + rows = await asyncio.to_thread(_search) + mapping = { + 'content_field': 'content', + 'metadata_field': 'metadata', + 'document_id_field': 'id', + 'score_field': 'distance', + } + return [_normalize_result(row, mapping, knowledge, distance=row.get('distance')) for row in rows] + + +async def retrieve_external_knowledge( + request, + knowledge: KnowledgeModel, + queries: list[str], + count: int, + user=None, +) -> dict: + external = (knowledge.meta or {}).get('external', {}) + connection_id = external.get('connection_id') + if not connection_id: + raise RuntimeError('External knowledge connection is not configured') + + connection = await _get_external_connection(connection_id) + if not connection: + raise RuntimeError('External knowledge connection not found') + + return await retrieve_external_knowledge_for_connection(request, knowledge, connection, queries, count, user=user) + + +async def retrieve_external_knowledge_for_connection( + request, + knowledge: KnowledgeModel, + connection: dict, + queries: list[str], + count: int, + user=None, +) -> dict: + auth_config = connection.get('auth_config') or {} + if not connection.get('enabled', True): + raise RuntimeError('External knowledge connection is disabled') + + started_at = time.monotonic() + chunks = [] + provider = (connection.get('provider') or '').lower() + + for query in queries: + if provider == 'qdrant': + chunks.extend( + await _retrieve_qdrant( + connection, + auth_config, + knowledge, + query, + count, + getattr(request.app.state, 'EMBEDDING_FUNCTION', None), + ) + ) + elif provider == 'milvus': + chunks.extend( + await _retrieve_milvus( + connection, + auth_config, + knowledge, + query, + count, + getattr(request.app.state, 'EMBEDDING_FUNCTION', None), + ) + ) + elif provider == 'pgvector': + chunks.extend( + await _retrieve_pgvector( + connection, + auth_config, + knowledge, + query, + count, + getattr(request.app.state, 'EMBEDDING_FUNCTION', None), + ) + ) + else: + raise RuntimeError(f'Unsupported external knowledge provider: {connection.get("provider")}') + + chunks = chunks[:count] + log.info( + 'external_knowledge_retrieval knowledge_id=%s connection_id=%s provider=%s user_id=%s latency_ms=%s result_count=%s', + knowledge.id, + connection.get('id'), + connection.get('provider'), + getattr(user, 'id', None), + round((time.monotonic() - started_at) * 1000), + len(chunks), + ) + + return { + 'documents': [[chunk['content'] for chunk in chunks]], + 'metadatas': [[chunk['metadata'] for chunk in chunks]], + 'distances': [[chunk['distance'] for chunk in chunks]], + } diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index c8c603f3d81..f805fb40907 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -43,6 +43,7 @@ from open_webui.models.users import UserModel from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT +from open_webui.retrieval.external import retrieve_external_knowledge from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.main import GetResult, SearchResult from open_webui.retrieval.web.utils import get_web_loader @@ -84,6 +85,7 @@ def is_youtube_url(url: str) -> bool: 'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format', 'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url', 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', + 'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers', 'TIKA_SERVER_URL': 'rag.tika_server_url', 'DOCLING_SERVER_URL': 'rag.docling_server_url', 'DOCLING_API_KEY': 'rag.docling_api_key', @@ -130,18 +132,16 @@ def build_loader_from_config(request, config: dict): """Build a Loader instance with the admin's configured extraction engine settings.""" from open_webui.retrieval.loaders.main import Loader - loader_config = { - key: config.get(key) - for key in LOADER_CONFIG_KEYS - if key.isupper() - } + loader_config = {key: config.get(key) for key in LOADER_CONFIG_KEYS if key.isupper()} return Loader( engine=loader_config['CONTENT_EXTRACTION_ENGINE'], **{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'}, ) -def _extract_text_from_binary_response(request, response: requests.Response, url: str, loader_config: dict) -> tuple[str, list]: +def _extract_text_from_binary_response( + request, response: requests.Response, url: str, loader_config: dict +) -> tuple[str, list]: """Download response body to a temp file and extract text using the Loader pipeline.""" import mimetypes import tempfile @@ -774,11 +774,7 @@ async def process_native_query(collection_name, query): return result native_task_results = await asyncio.gather( - *[ - process_native_query(collection_name, query) - for collection_name in collection_names - for query in queries - ] + *[process_native_query(collection_name, query) for collection_name in collection_names for query in queries] ) if native_task_results and all(result is not None for result in native_task_results): return merge_and_sort_query_results(native_task_results, k=k) @@ -1485,50 +1481,61 @@ async def get_sources_from_items( permission='read', ) ): - if item.get('context') == 'full' or bypass_embedding_and_retrieval: - if knowledge_base and ( - user.role == 'admin' - or knowledge_base.user_id == user.id - or await AccessGrants.has_access( - user_id=user.id, - resource_type='knowledge', - resource_id=knowledge_base.id, - permission='read', - ) - ): - files = await Knowledges.get_files_by_id(knowledge_base.id) - - documents = [] - metadatas = [] - for file in files: - documents.append(file.data.get('content', '')) - metadatas.append( - { - 'file_id': file.id, - 'name': file.filename, - 'source': file.filename, - } - ) + if (knowledge_base.meta or {}).get('source') == 'external': + query_result = await retrieve_external_knowledge( + request, + knowledge_base, + queries=queries, + count=k, + user=user, + ) + extracted_collections.append(knowledge_base.id) - query_result = { - 'documents': [documents], - 'metadatas': [metadatas], - } else: - if item.get('legacy'): - if BYPASS_RETRIEVAL_ACCESS_CONTROL: - collection_names = item.get('collection_names', []) - else: - # Legacy KB: item.collection_names is client-supplied. - # Validate against the KB's actual files to prevent - # cross-tenant collection name substitution. + if item.get('context') == 'full' or bypass_embedding_and_retrieval: + if knowledge_base and ( + user.role == 'admin' + or knowledge_base.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='knowledge', + resource_id=knowledge_base.id, + permission='read', + ) + ): files = await Knowledges.get_files_by_id(knowledge_base.id) - owned_names = {f'file-{f.id}' for f in files} - owned_names.add(knowledge_base.id) - valid_names = [n for n in (item.get('collection_names') or []) if n in owned_names] - collection_names = valid_names if valid_names else [knowledge_base.id] + + documents = [] + metadatas = [] + for file in files: + documents.append(file.data.get('content', '')) + metadatas.append( + { + 'file_id': file.id, + 'name': file.filename, + 'source': file.filename, + } + ) + + query_result = { + 'documents': [documents], + 'metadatas': [metadatas], + } else: - collection_names.append(item['id']) + if item.get('legacy'): + if BYPASS_RETRIEVAL_ACCESS_CONTROL: + collection_names = item.get('collection_names', []) + else: + # Legacy KB: item.collection_names is client-supplied. + # Validate against the KB's actual files to prevent + # cross-tenant collection name substitution. + files = await Knowledges.get_files_by_id(knowledge_base.id) + owned_names = {f'file-{f.id}' for f in files} + owned_names.add(knowledge_base.id) + valid_names = [n for n in (item.get('collection_names') or []) if n in owned_names] + collection_names = valid_names if valid_names else [knowledge_base.id] + else: + collection_names.append(item['id']) elif item.get('docs'): # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 3b68671c280..54c61772452 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -3,6 +3,8 @@ import asyncio import io import logging +import time +import uuid import zipfile from typing import List, Optional from urllib.parse import quote @@ -27,6 +29,7 @@ ) from open_webui.models.models import ModelForm, Models from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT +from open_webui.retrieval.external import retrieve_external_knowledge, retrieve_external_knowledge_for_connection from open_webui.routers.retrieval import ( BatchProcessFilesForm, ProcessFileForm, @@ -110,6 +113,17 @@ class KnowledgeAccessListResponse(BaseModel): total: int +def is_external_knowledge(knowledge) -> bool: + return (knowledge.meta or {}).get('source') == 'external' + + +def external_knowledge_error(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='External knowledge bases are read-only.', + ) + + @router.get('/', response_model=KnowledgeAccessListResponse) async def get_knowledge_bases( page: int | None = 1, @@ -163,6 +177,7 @@ async def get_knowledge_bases( async def search_knowledge_bases( query: str | None = None, view_option: str | None = None, + source: str | None = None, page: int | None = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), @@ -176,6 +191,8 @@ async def search_knowledge_bases( filter['query'] = query if view_option: filter['view_option'] = view_option + if source in {'local', 'external'}: + filter['source'] = source groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} @@ -379,6 +396,574 @@ async def reindex_knowledge_base_metadata_embeddings( return {'total': len(knowledge_bases), 'success': success_count} +############################ +# External Knowledge Sources +############################ + + +class ExternalKnowledgeSourceForm(BaseModel): + type: str = 'collection' + name: str + config: Optional[dict] = None + + +class ExternalKnowledgeCreateForm(BaseModel): + name: str + description: str = '' + connection_id: str + source: ExternalKnowledgeSourceForm + access_grants: Optional[list[dict]] = None + + +class ExternalKnowledgeSourceCreateForm(BaseModel): + name: str + description: str = '' + connection: ExternalKnowledgeConnectionForm + source: ExternalKnowledgeSourceForm + access_grants: Optional[list[dict]] = None + test_query: str + test_count: int = 5 + + +class ExternalKnowledgeSourceUpdateForm(ExternalKnowledgeSourceCreateForm): + pass + + +class ExternalKnowledgeSourceTestForm(BaseModel): + connection_id: Optional[str] = None + connection: ExternalKnowledgeConnectionForm + source: ExternalKnowledgeSourceForm + query: str + count: int = 5 + + +class ExternalKnowledgeRetrieveTestForm(BaseModel): + query: str + source: Optional[ExternalKnowledgeSourceForm] = None + count: int = 5 + + +class ExternalKnowledgeConnectionForm(BaseModel): + name: str + provider: str + endpoint: str + auth_config: Optional[dict] = None + config: Optional[dict] = None + capabilities: Optional[dict] = None + enabled: bool = True + + +class ExternalKnowledgeConnectionListResponse(BaseModel): + items: list[dict] + total: int + + +EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections' +EXTERNAL_KNOWLEDGE_PROVIDERS = {'qdrant', 'milvus', 'pgvector'} + + +def _validate_external_connection_form(form_data: ExternalKnowledgeConnectionForm) -> tuple[str, dict]: + provider = form_data.provider.lower().strip() + if provider not in EXTERNAL_KNOWLEDGE_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Unsupported external knowledge provider.', + ) + + if not form_data.name.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge source name is required.') + + if not form_data.endpoint.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge source endpoint is required.') + + config = form_data.config or {} + allowed_config_keys = {'timeout'} + if provider == 'milvus': + allowed_config_keys.add('db_name') + + return provider, {key: value for key, value in config.items() if key in allowed_config_keys} + + +def _external_auth_config(provider: str, incoming: Optional[dict], existing: Optional[dict] = None) -> dict: + if provider == 'pgvector': + return {} + return existing if incoming is None else incoming or {} + + +def _normalize_external_source(source: ExternalKnowledgeSourceForm, provider: str) -> ExternalKnowledgeSourceForm: + source.type = (source.type or 'collection').strip() + source.name = source.name.strip() + + if source.type != 'collection': + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Only collection sources are supported.') + if not source.name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Collection name is required.') + + config = source.config or {} + allowed_keys = {'content_field', 'metadata_field', 'document_id_field'} + if provider in {'qdrant', 'milvus'}: + allowed_keys.add('vector_field') + if provider == 'pgvector': + allowed_keys.update({'table_name', 'collection_field', 'vector_field'}) + + normalized_config = { + key: value.strip() if isinstance(value, str) else value + for key, value in config.items() + if key in allowed_keys and value is not None and (not isinstance(value, str) or value.strip()) + } + + if not normalized_config.get('content_field'): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Content field is required.') + if provider in {'milvus', 'pgvector'} and not normalized_config.get('vector_field'): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Vector field is required.') + + source.config = normalized_config + return source + + +def _sanitize_external_connection(connection: dict) -> dict: + sanitized = {**connection} + sanitized.pop('auth_config', None) + sanitized['auth_configured'] = bool(connection.get('auth_config')) + return sanitized + + +async def _get_external_connections() -> list[dict]: + return await Config.get(EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY, []) or [] + + +async def _set_external_connections(connections: list[dict]) -> None: + await Config.upsert({EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY: connections}) + + +def _external_connection_dict(form_data: ExternalKnowledgeConnectionForm, user_id: str, id: Optional[str] = None) -> dict: + provider, config = _validate_external_connection_form(form_data) + now = int(time.time()) + return { + 'id': id or str(uuid.uuid4()), + 'name': form_data.name.strip(), + 'provider': provider, + 'endpoint': form_data.endpoint.strip(), + 'auth_config': _external_auth_config(provider, form_data.auth_config), + 'config': config, + 'capabilities': form_data.capabilities or {'retrieve': True}, + 'health': None, + 'enabled': form_data.enabled, + 'created_by': user_id, + 'created_at': now, + 'updated_at': now, + } + + +def _external_connection_update_dict( + form_data: ExternalKnowledgeConnectionForm, + existing: dict, +) -> dict: + provider, config = _validate_external_connection_form(form_data) + return { + **existing, + 'name': form_data.name.strip(), + 'provider': provider, + 'endpoint': form_data.endpoint.strip(), + 'auth_config': _external_auth_config(provider, form_data.auth_config, existing.get('auth_config')) or {}, + 'config': config, + 'capabilities': form_data.capabilities or {'retrieve': True}, + 'enabled': form_data.enabled, + 'updated_at': int(time.time()), + } + + +async def _get_external_connection(id: str) -> Optional[dict]: + connections = await _get_external_connections() + return next((connection for connection in connections if connection.get('id') == id), None) + + +async def _count_external_connection_mappings(connection_id: str, db: Optional[AsyncSession] = None) -> int: + count = 0 + for knowledge in await Knowledges.get_knowledge_bases(db=db): + if (knowledge.meta or {}).get('external', {}).get('connection_id') == connection_id: + count += 1 + return count + + +@router.get('/external/connections', response_model=ExternalKnowledgeConnectionListResponse) +async def get_external_knowledge_connections( + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connections = [_sanitize_external_connection(connection) for connection in await _get_external_connections()] + return ExternalKnowledgeConnectionListResponse(items=connections, total=len(connections)) + + +@router.post('/external/connections', response_model=dict) +async def create_external_knowledge_connection( + form_data: ExternalKnowledgeConnectionForm, + user=Depends(get_admin_user), +): + connections = await _get_external_connections() + connection = _external_connection_dict(form_data, user.id) + connections.append(connection) + await _set_external_connections(connections) + return _sanitize_external_connection(connection) + + +@router.get('/external/connections/{id}', response_model=dict) +async def get_external_knowledge_connection( + id: str, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connection = await _get_external_connection(id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + return _sanitize_external_connection(connection) + + +@router.patch('/external/connections/{id}', response_model=dict) +async def update_external_knowledge_connection( + id: str, + form_data: ExternalKnowledgeConnectionForm, + user=Depends(get_admin_user), +): + connections = await _get_external_connections() + idx = next((idx for idx, connection in enumerate(connections) if connection.get('id') == id), None) + if idx is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + connection = _external_connection_update_dict(form_data, connections[idx]) + connections[idx] = connection + await _set_external_connections(connections) + return _sanitize_external_connection(connection) + + +@router.delete('/external/connections/{id}', response_model=bool) +async def delete_external_knowledge_connection( + id: str, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connection = await _get_external_connection(id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if await _count_external_connection_mappings(id, db=db) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='External connection is still used by knowledge bases.', + ) + + connections = [connection for connection in await _get_external_connections() if connection.get('id') != id] + await _set_external_connections(connections) + return True + + +@router.post('/external/connections/{id}/test', response_model=dict) +async def test_external_knowledge_connection( + id: str, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connection = await _get_external_connection(id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + health = { + 'ok': bool(connection.get('enabled') and connection.get('endpoint')), + 'provider': connection.get('provider'), + 'checked_at': int(time.time()), + } + connections = await _get_external_connections() + for item in connections: + if item.get('id') == id: + item['health'] = health + item['updated_at'] = int(time.time()) + break + await _set_external_connections(connections) + return health + + +async def _test_external_source_definition( + request: Request, + connection: dict, + source: ExternalKnowledgeSourceForm, + query: str, + count: int, + user, +) -> dict: + if not query.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query is required.') + + source = _normalize_external_source(source, connection.get('provider')) + test_knowledge = KnowledgeResponse( + id='external-test', + user_id=user.id, + name=connection.get('name'), + description='', + meta={ + 'source': 'external', + 'read_only': True, + 'external': { + 'connection_id': connection.get('id'), + 'source': source.model_dump(), + 'provider': connection.get('provider'), + 'auth_mode': 'service_account', + 'capabilities': {'retrieve': True}, + }, + }, + access_grants=[], + created_at=int(time.time()), + updated_at=int(time.time()), + ) + result = await retrieve_external_knowledge_for_connection( + request, + test_knowledge, + connection, + [query.strip()], + count, + user=user, + ) + return { + 'documents': result.get('documents', [[]])[0], + 'metadatas': result.get('metadatas', [[]])[0], + 'distances': result.get('distances', [[]])[0], + } + + +@router.post('/external/source/test', response_model=dict) +async def test_external_knowledge_source( + request: Request, + form_data: ExternalKnowledgeSourceTestForm, + user=Depends(get_admin_user), +): + if form_data.connection_id: + existing_connection = await _get_external_connection(form_data.connection_id) + if not existing_connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.') + connection = _external_connection_update_dict(form_data.connection, existing_connection) + else: + connection = _external_connection_dict(form_data.connection, user.id, id='external-test') + + return await _test_external_source_definition( + request, + connection, + form_data.source, + form_data.query, + form_data.count, + user, + ) + + +@router.post('/external/connections/{id}/retrieve-test', response_model=dict) +async def test_external_knowledge_retrieval( + request: Request, + id: str, + form_data: ExternalKnowledgeRetrieveTestForm, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connection = await _get_external_connection(id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + source = form_data.source or ExternalKnowledgeSourceForm(name='test', config={'content_field': 'payload.text'}) + return await _test_external_source_definition(request, connection, source, form_data.query, form_data.count, user) + + +@router.post('/external/knowledge/create', response_model=KnowledgeResponse | None) +async def create_external_knowledge( + request: Request, + form_data: ExternalKnowledgeCreateForm, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + connection = await _get_external_connection(form_data.connection_id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + if not form_data.name.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.') + source = _normalize_external_source(form_data.source, connection.get('provider')) + + form_data.access_grants = await filter_allowed_access_grants( + await Config.get('user.permissions'), + user.id, + user.role, + form_data.access_grants, + 'sharing.public_knowledge', + ) + + knowledge = await Knowledges.insert_new_knowledge( + user.id, + KnowledgeForm( + name=form_data.name.strip(), + description=form_data.description, + access_grants=form_data.access_grants, + ), + db=db, + ) + if not knowledge: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FILE_EXISTS) + + meta = { + 'source': 'external', + 'read_only': True, + 'external': { + 'connection_id': form_data.connection_id, + 'source': source.model_dump(), + 'provider': connection.get('provider'), + 'auth_mode': 'service_account', + 'capabilities': {'retrieve': True}, + }, + } + knowledge = await Knowledges.update_knowledge_meta_by_id(knowledge.id, meta, db=db) + await embed_knowledge_base_metadata(request, knowledge.id, knowledge.name, knowledge.description) + return knowledge + + +@router.post('/external/source/create', response_model=KnowledgeResponse | None) +async def create_external_knowledge_source( + request: Request, + form_data: ExternalKnowledgeSourceCreateForm, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + if not form_data.name.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.') + + connection = _external_connection_dict(form_data.connection, user.id) + source = _normalize_external_source(form_data.source, connection.get('provider')) + test_result = await _test_external_source_definition( + request, + connection, + source, + form_data.test_query, + form_data.test_count, + user, + ) + if not test_result.get('documents'): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query returned no results.') + + form_data.access_grants = await filter_allowed_access_grants( + await Config.get('user.permissions'), + user.id, + user.role, + form_data.access_grants, + 'sharing.public_knowledge', + ) + + connections = await _get_external_connections() + connections.append(connection) + await _set_external_connections(connections) + + knowledge = await Knowledges.insert_new_knowledge( + user.id, + KnowledgeForm( + name=form_data.name.strip(), + description=form_data.description, + access_grants=form_data.access_grants, + ), + db=db, + ) + if not knowledge: + connections = [item for item in await _get_external_connections() if item.get('id') != connection.get('id')] + await _set_external_connections(connections) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FILE_EXISTS) + + meta = { + 'source': 'external', + 'read_only': True, + 'external': { + 'connection_id': connection.get('id'), + 'source': source.model_dump(), + 'provider': connection.get('provider'), + 'auth_mode': 'service_account', + 'capabilities': {'retrieve': True}, + }, + } + knowledge = await Knowledges.update_knowledge_meta_by_id(knowledge.id, meta, db=db) + await embed_knowledge_base_metadata(request, knowledge.id, knowledge.name, knowledge.description) + return knowledge + + +@router.patch('/external/source/{id}', response_model=KnowledgeResponse | None) +async def update_external_knowledge_source( + request: Request, + id: str, + form_data: ExternalKnowledgeSourceUpdateForm, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) + if not knowledge or not is_external_knowledge(knowledge): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + if not form_data.name.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.') + + connection_id = (knowledge.meta or {}).get('external', {}).get('connection_id') + connections = await _get_external_connections() + idx = next((idx for idx, connection in enumerate(connections) if connection.get('id') == connection_id), None) + if idx is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.') + + existing_connection = connections[idx] + connection = _external_connection_update_dict(form_data.connection, existing_connection) + source = _normalize_external_source(form_data.source, connection.get('provider')) + test_result = await _test_external_source_definition( + request, + connection, + source, + form_data.test_query, + form_data.test_count, + user, + ) + if not test_result.get('documents'): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query returned no results.') + + form_data.access_grants = await filter_allowed_access_grants( + await Config.get('user.permissions'), + user.id, + user.role, + form_data.access_grants, + 'sharing.public_knowledge', + ) + + connections[idx] = connection + await _set_external_connections(connections) + + updated = await Knowledges.update_knowledge_by_id( + id=id, + form_data=KnowledgeForm( + name=form_data.name.strip(), + description=form_data.description, + access_grants=form_data.access_grants, + ), + db=db, + ) + if not updated: + connections[idx] = existing_connection + await _set_external_connections(connections) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + + meta = { + 'source': 'external', + 'read_only': True, + 'external': { + 'connection_id': connection.get('id'), + 'source': source.model_dump(), + 'provider': connection.get('provider'), + 'auth_mode': 'service_account', + 'capabilities': {'retrieve': True}, + }, + } + updated = await Knowledges.update_knowledge_meta_by_id(id, meta, db=db) + if not updated: + connections[idx] = existing_connection + await _set_external_connections(connections) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + + await embed_knowledge_base_metadata(request, id, updated.name, updated.description) + return updated + + ############################ # GetKnowledgeById ############################ @@ -711,6 +1296,8 @@ async def add_file_to_knowledge_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id @@ -798,6 +1385,8 @@ async def update_file_from_knowledge_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id @@ -877,6 +1466,8 @@ async def remove_file_from_knowledge_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id @@ -1004,11 +1595,21 @@ async def delete_knowledge_by_id( await Models.update_model_by_id(model.id, model_form, db=db) # Clean up vector DB - try: - await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) - except Exception as e: - log.debug(e) - pass + if is_external_knowledge(knowledge): + connection_id = (knowledge.meta or {}).get('external', {}).get('connection_id') + if connection_id: + connections = [ + connection + for connection in await _get_external_connections() + if connection.get('id') != connection_id + ] + await _set_external_connections(connections) + else: + try: + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) + except Exception as e: + log.debug(e) + pass # Remove knowledge base embedding await remove_knowledge_base_metadata_embedding(id) @@ -1035,6 +1636,8 @@ async def reset_knowledge_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id @@ -1263,6 +1866,8 @@ async def add_files_to_knowledge_batch( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id @@ -1380,6 +1985,8 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Asyn status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() files = await Knowledges.get_files_by_id(id, db=db) @@ -1441,6 +2048,8 @@ async def _verify_knowledge_write_access(id: str, user, db: AsyncSession): status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if is_external_knowledge(knowledge): + external_knowledge_error() if ( knowledge.user_id != user.id and not await AccessGrants.has_access( diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index c49af3af1b5..0017a13b020 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -288,6 +288,7 @@ def get_rf( 'ENABLE_WEB_SEARCH': 'rag.web.search.enable', 'EXA_API_KEY': 'rag.web.search.exa_api_key', 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', + 'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers', 'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url', 'EXTERNAL_WEB_LOADER_API_KEY': 'rag.web.loader.external_web_loader_api_key', 'EXTERNAL_WEB_LOADER_URL': 'rag.web.loader.external_web_loader_url', @@ -632,6 +633,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'DATALAB_MARKER_OUTPUT_FORMAT': config.DATALAB_MARKER_OUTPUT_FORMAT, 'EXTERNAL_DOCUMENT_LOADER_URL': config.EXTERNAL_DOCUMENT_LOADER_URL, 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'EXTERNAL_DOCUMENT_LOADER_HEADERS': config.EXTERNAL_DOCUMENT_LOADER_HEADERS, 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, 'DOCLING_API_KEY': config.DOCLING_API_KEY, @@ -846,6 +848,7 @@ class ConfigForm(BaseModel): EXTERNAL_DOCUMENT_LOADER_URL: str | None = None EXTERNAL_DOCUMENT_LOADER_API_KEY: str | None = None + EXTERNAL_DOCUMENT_LOADER_HEADERS: dict | None = None TIKA_SERVER_URL: str | None = None DOCLING_SERVER_URL: str | None = None @@ -1021,6 +1024,11 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend if form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY is not None else config.EXTERNAL_DOCUMENT_LOADER_API_KEY ) + config.EXTERNAL_DOCUMENT_LOADER_HEADERS = ( + form_data.EXTERNAL_DOCUMENT_LOADER_HEADERS + if form_data.EXTERNAL_DOCUMENT_LOADER_HEADERS is not None + else config.EXTERNAL_DOCUMENT_LOADER_HEADERS + ) config.TIKA_SERVER_URL = ( form_data.TIKA_SERVER_URL if form_data.TIKA_SERVER_URL is not None else config.TIKA_SERVER_URL ) @@ -1336,6 +1344,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'DATALAB_MARKER_OUTPUT_FORMAT': config.DATALAB_MARKER_OUTPUT_FORMAT, 'EXTERNAL_DOCUMENT_LOADER_URL': config.EXTERNAL_DOCUMENT_LOADER_URL, 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'EXTERNAL_DOCUMENT_LOADER_HEADERS': config.EXTERNAL_DOCUMENT_LOADER_HEADERS, 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, 'DOCLING_API_KEY': config.DOCLING_API_KEY, @@ -1843,6 +1852,11 @@ async def process_file( loader_config = await get_loader_config() loader = build_loader_from_config(request, loader_config) loader.user = user + loader.metadata = { + 'file_id': file.id, + 'file_name': file.filename, + 'file_content_type': file.meta.get('content_type'), + } docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path) docs = [ diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 47d1657df7b..8e5011cb7f9 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -514,21 +514,14 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): elif engine == 'jupyter': from open_webui.utils.code_interpreter import execute_code_jupyter + jupyter_auth = await Config.get('code_interpreter.jupyter.auth') output = await execute_code_jupyter( await Config.get('code_interpreter.jupyter.url'), code, - ( - await Config.get('code_interpreter.jupyter.auth_token') - if jupyter_auth == 'token' - else None - ), - ( - await Config.get('code_interpreter.jupyter.auth_password') - if jupyter_auth == 'password' - else None - ), + (await Config.get('code_interpreter.jupyter.auth_token') if jupyter_auth == 'token' else None), + (await Config.get('code_interpreter.jupyter.auth_password') if jupyter_auth == 'password' else None), await Config.get('code_interpreter.jupyter.timeout'), ) @@ -2385,6 +2378,7 @@ async def query_knowledge_files( from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges from open_webui.models.notes import Notes + from open_webui.retrieval.external import retrieve_external_knowledge from open_webui.retrieval.utils import query_collection user_id = __user__.get('id') @@ -2396,6 +2390,7 @@ async def query_knowledge_files( return json.dumps({'error': 'Embedding function not configured'}) collection_names = [] + external_knowledges = [] note_results = [] # Notes aren't vectorized, handle separately # If model has attached knowledge, use those @@ -2418,7 +2413,10 @@ async def query_knowledge_files( user_group_ids=set(user_group_ids), ) ): - collection_names.append(item_id) + if (knowledge.meta or {}).get('source') == 'external': + external_knowledges.append(knowledge) + else: + collection_names.append(item_id) elif item_type == 'file': # Individual file - use file-{id} as collection name @@ -2464,7 +2462,10 @@ async def query_knowledge_files( user_group_ids=set(user_group_ids), ) ): - collection_names.append(knowledge_id) + if (knowledge.meta or {}).get('source') == 'external': + external_knowledges.append(knowledge) + else: + collection_names.append(knowledge_id) else: # No model knowledge and no specific IDs - search all accessible KBs result = await Knowledges.search_knowledge_bases( @@ -2477,7 +2478,11 @@ async def query_knowledge_files( skip=0, limit=50, ) - collection_names = [knowledge_base.id for knowledge_base in result.items] + for knowledge_base in result.items: + if (knowledge_base.meta or {}).get('source') == 'external': + external_knowledges.append(knowledge_base) + else: + collection_names.append(knowledge_base.id) chunks = [] @@ -2509,6 +2514,31 @@ async def query_knowledge_files( chunk_info['distance'] = distances[idx] chunks.append(chunk_info) + for knowledge in external_knowledges: + query_results = await retrieve_external_knowledge( + __request__, + knowledge, + queries=[query], + count=count, + user=type('UserContext', (), {'id': user_id, 'role': user_role})(), + ) + documents = query_results.get('documents', [[]])[0] + metadatas = query_results.get('metadatas', [[]])[0] + distances = query_results.get('distances', [[]])[0] + + for idx, doc in enumerate(documents): + metadata = metadatas[idx] if idx < len(metadatas) else {} + chunk_info = { + 'content': doc, + 'source': metadata.get('source', metadata.get('name', knowledge.name)), + 'file_id': metadata.get('file_id', f'external-{knowledge.id}'), + 'type': 'external', + 'knowledge_id': knowledge.id, + } + if idx < len(distances): + chunk_info['distance'] = distances[idx] + chunks.append(chunk_info) + # Limit to requested count chunks = chunks[:count] diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index d99e36f2841..feb63ccdd83 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -38,6 +38,304 @@ export const createNewKnowledge = async ( return res; }; +export const getExternalKnowledgeConnections = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createExternalKnowledgeConnection = async (token: string, connection: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(connection) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateExternalKnowledgeConnection = async ( + token: string, + id: string, + connection: object +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}`, { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(connection) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteExternalKnowledgeConnection = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const testExternalKnowledgeConnection = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}/test`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const testExternalKnowledgeRetrieval = async ( + token: string, + id: string, + payload: object +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}/retrieve-test`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const testExternalKnowledgeSource = async (token: string, payload: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/test`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createExternalKnowledgeSource = async (token: string, payload: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateExternalKnowledgeSource = async (token: string, id: string, payload: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/${id}`, { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createExternalKnowledge = async (token: string, payload: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/knowledge/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getKnowledgeBases = async (token: string = '', page: number | null = null) => { let error = null; @@ -76,13 +374,15 @@ export const searchKnowledgeBases = async ( token: string = '', query: string | null = null, viewOption: string | null = null, - page: number | null = null + page: number | null = null, + source: string | null = null ) => { let error = null; const searchParams = new URLSearchParams(); if (query) searchParams.append('query', query); if (viewOption) searchParams.append('view_option', viewOption); + if (source) searchParams.append('source', source); if (page) searchParams.append('page', page.toString()); const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, { diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index a84e7b68225..ffab4064096 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -54,6 +54,7 @@ type RAGConfigForm = { PDF_EXTRACT_IMAGES?: boolean; ENABLE_GOOGLE_DRIVE_INTEGRATION?: boolean; ENABLE_ONEDRIVE_INTEGRATION?: boolean; + EXTERNAL_DOCUMENT_LOADER_HEADERS?: Record; chunk?: ChunkConfigForm; content_extraction?: ContentExtractConfigForm; web_loader_ssl_verification?: boolean; diff --git a/src/lib/components/admin/Settings.svelte b/src/lib/components/admin/Settings.svelte index 1927e996571..2d750660314 100644 --- a/src/lib/components/admin/Settings.svelte +++ b/src/lib/components/admin/Settings.svelte @@ -155,7 +155,21 @@ id: 'integrations', title: 'Integrations', route: '/admin/settings/integrations', - keywords: ['tools', 'integrations', 'plugins', 'extensions', 'functions', 'openapi', 'server'] + keywords: [ + 'tools', + 'integrations', + 'plugins', + 'extensions', + 'functions', + 'openapi', + 'server', + 'knowledge', + 'vector db', + 'qdrant', + 'rag', + 'retrieval', + 'sources' + ] }, { id: 'documents', diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 92706ff94d8..de40f84cf74 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -37,6 +37,7 @@ let showResetConfirm = false; let showResetUploadDirConfirm = false; let showReindexConfirm = false; + let showExternalDocumentLoaderHeadersHint = false; let RAG_EMBEDDING_ENGINE = ''; let RAG_EMBEDDING_MODEL = ''; @@ -149,6 +150,21 @@ toast.error($i18n.t('External Document Loader URL required.')); return; } + if ( + RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external' && + RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS + ) { + try { + const headers = JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS); + if (headers === null || typeof headers !== 'object' || Array.isArray(headers)) { + throw new Error('Headers must be a valid JSON object'); + } + RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS = JSON.stringify(headers, null, 2); + } catch (error) { + toast.error($i18n.t('Headers must be a valid JSON object')); + return; + } + } if (RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika' && RAGConfig.TIKA_SERVER_URL === '') { toast.error($i18n.t('Tika Server URL required.')); return; @@ -241,6 +257,11 @@ typeof RAGConfig.DOCLING_PARAMS === 'string' && RAGConfig.DOCLING_PARAMS.trim() !== '' ? JSON.parse(RAGConfig.DOCLING_PARAMS) : {}, + EXTERNAL_DOCUMENT_LOADER_HEADERS: + typeof RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS === 'string' && + RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS.trim() !== '' + ? JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS) + : {}, MINERU_PARAMS: typeof RAGConfig.MINERU_PARAMS === 'string' && RAGConfig.MINERU_PARAMS.trim() !== '' ? JSON.parse(RAGConfig.MINERU_PARAMS) @@ -289,6 +310,13 @@ ? JSON.stringify(config.MINERU_PARAMS ?? {}, null, 2) : config.MINERU_PARAMS; + config.EXTERNAL_DOCUMENT_LOADER_HEADERS = + typeof config.EXTERNAL_DOCUMENT_LOADER_HEADERS === 'object' + ? Object.keys(config.EXTERNAL_DOCUMENT_LOADER_HEADERS ?? {}).length > 0 + ? JSON.stringify(config.EXTERNAL_DOCUMENT_LOADER_HEADERS, null, 2) + : '' + : config.EXTERNAL_DOCUMENT_LOADER_HEADERS; + config.MINERU_FILE_EXTENSIONS = (config?.MINERU_FILE_EXTENSIONS ?? ['pdf']).join(', '); RAGConfig = config; @@ -581,17 +609,78 @@
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external'} -
- - +
+
+ + +
+
+
{$i18n.t('Headers')}
+ + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ + {#if sourceForm.provider !== 'pgvector'} +
+
+ +
+
+ +
+
+ {/if} +
+ + {#if sourceForm.provider === 'milvus'} +
+
+
+ +
+
+ +
+
+
+ {/if} + +
+ +
+
+
+ +
+
+ +
+
+
+ + {#if sourceForm.provider === 'pgvector'} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ {/if} + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ + + + + +
+ +
+ {$i18n.t( + 'External vectors must be generated with the same embedding model configured in Open WebUI.' + )} +
+
+
+ +
+ + + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
{$i18n.t('External Knowledge Sources')}
+ {$i18n.t('Experimental')} +
+ + + + +
+ +
+ {#each items as item} + {@const connection = connectionForItem(item)} +
+ +
+
+ + + + +
+ {item.name} + {' '} + + {item?.meta?.external?.provider ?? connection?.provider} + {item?.meta?.external?.source?.name ? `· ${item.meta.external.source.name}` : ''} + +
+
+
+
+ +
+ + + + + + { + toggleSource(item); + }} + /> + +
+
+ {/each} +
+ + {#if loading} +
+ +
+ {:else if items.length === 0} +
+ {$i18n.t('No external knowledge sources configured.')} +
+ {/if} + +
+
+ {$i18n.t( + 'Create one read-only Knowledge source per external collection. Test must pass before the source is created.' + )} +
+
+
diff --git a/src/lib/components/admin/Settings/Integrations.svelte b/src/lib/components/admin/Settings/Integrations.svelte index 1792104165b..81286256f4e 100644 --- a/src/lib/components/admin/Settings/Integrations.svelte +++ b/src/lib/components/admin/Settings/Integrations.svelte @@ -1,11 +1,12 @@ @@ -241,6 +265,19 @@ await tick(); }} /> + +
@@ -267,11 +304,23 @@
-
- -
+ {#if item?.meta?.source === 'external'} +
+ +
+
+ +
+ {:else} +
+ +
+ {/if} - {#if !item?.write_access} + {#if !item?.write_access && item?.meta?.source !== 'external'}
@@ -282,7 +331,7 @@
{ exportHandler(item); } diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 82935f20800..3beefaf2fb5 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -5,7 +5,10 @@ import { PaneGroup, Pane, PaneResizer } from 'paneforge'; import { onMount, getContext, onDestroy, tick } from 'svelte'; - const i18n = getContext('i18n'); + import type { Writable } from 'svelte/store'; + import type { i18n as i18nType } from 'i18next'; + + const i18n = getContext>('i18n'); import { goto } from '$app/navigation'; import { page } from '$app/stores'; @@ -40,7 +43,8 @@ deleteKnowledgeDirectory, moveFileInKnowledge, syncKnowledgeDiff, - syncKnowledgeCleanup + syncKnowledgeCleanup, + testExternalKnowledgeRetrieval } from '$lib/apis/knowledge'; import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval'; @@ -98,11 +102,13 @@ files: any[]; access_grants?: any[]; write_access?: boolean; + meta?: any; }; let id = null; let knowledge: Knowledge | null = null; let knowledgeId = null; + let isExternalKnowledge = false; let selectedFileId = null; let selectedFile = null; @@ -132,6 +138,14 @@ let deleteDirectoryContents = true; let pendingPollTimer: ReturnType | null = null; + let externalTestQuery = ''; + let externalTestResult: { + documents?: string[]; + metadatas?: Record[]; + distances?: number[]; + } | null = null; + + $: isExternalKnowledge = knowledge?.meta?.source === 'external'; const reset = () => { currentPage = 1; @@ -243,6 +257,24 @@ } }; + const externalTestHandler = async () => { + if (!isExternalKnowledge || !externalTestQuery.trim()) return; + + const external = knowledge?.meta?.external ?? {}; + const res = await testExternalKnowledgeRetrieval(localStorage.token, external.connection_id, { + query: externalTestQuery, + source: external.source, + count: 5 + }).catch((e) => { + toast.error(`${e}`); + return null; + }); + + if (res) { + externalTestResult = res; + } + }; + const createFileFromText = (name, content) => { const blob = new Blob([content], { type: 'text/plain' }); const file = blobToFile(blob, `${name}.txt`); @@ -1299,284 +1331,357 @@
-
-
-
- + {#if isExternalKnowledge} +
+
+
+ {$i18n.t('Connected')} +
+
+ {$i18n.t('Read Only')} +
+
+ {knowledge?.meta?.external?.provider ?? $i18n.t('Provider')} +
+
+ {$i18n.t('Service Account')} +
- { - selectedFileId = null; - }} - /> - - - -
-
- +
+
+
{$i18n.t('Mapped Source')}
+
+ {knowledge?.meta?.external?.source?.name ?? $i18n.t('Not configured')}
- - - {#if knowledge?.write_access}
- { - if (data.type === 'directory') { - uploadDirectoryHandler(); - } else if (data.type === 'new_directory') { - showNewDirectoryModal = true; - } else if (data.type === 'web') { - showAddWebpageModal = true; - } else if (data.type === 'text') { - showAddTextContentModal = true; - } else { - document.getElementById('files-input').click(); - } - }} - onSync={async () => { - pendingSyncFiles = await collectDirectoryFiles(); - if (pendingSyncFiles?.length) { - showSyncConfirmModal = true; - } - }} - onReset={() => { - showResetConfirm = true; - }} +
{$i18n.t('Auth Mode')}
+
+ {$i18n.t('Admin-managed service account')} +
+
+
+ +
+ {$i18n.t( + 'This knowledge base retrieves from a connected source. Open WebUI can query it, but cannot upload, sync, edit, delete, reset, or reindex its source data.' + )} +
+ +
+
{$i18n.t('Test Query')}
+
+ + +
+
+ + {#if externalTestResult} +
+
{$i18n.t('Preview')}
+ {#each externalTestResult.documents ?? [] as document, idx} +
+
{document}
+
+ {externalTestResult.metadatas?.[idx]?.source ?? ''} +
+
+ {/each}
{/if}
-
- -
-
{ - if (e.deltaY !== 0) { - e.preventDefault(); - e.currentTarget.scrollLeft += e.deltaY; - } - }} - > -
- { - if (value) { - localStorage.workspaceViewOption = value; - } else { - delete localStorage.workspaceViewOption; - } + {:else} +
+
+
+ +
+ { + selectedFileId = null; }} /> - + + + +
+
+ +
+
+
- {#if sortKey} + {#if knowledge?.write_access} +
+ { + if (data.type === 'directory') { + uploadDirectoryHandler(); + } else if (data.type === 'new_directory') { + showNewDirectoryModal = true; + } else if (data.type === 'web') { + showAddWebpageModal = true; + } else if (data.type === 'text') { + showAddTextContentModal = true; + } else { + document.getElementById('files-input').click(); + } + }} + onSync={async () => { + pendingSyncFiles = await collectDirectoryFiles(); + if (pendingSyncFiles?.length) { + showSyncConfirmModal = true; + } + }} + onReset={() => { + showResetConfirm = true; + }} + /> +
+ {/if} +
+
+ +
+
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
{ + if (value) { + localStorage.workspaceViewOption = value; + } else { + delete localStorage.workspaceViewOption; + } + }} /> - {/if} -
-
-
- {#if currentDirectoryId !== null} -
- navigateToDirectory(dirId)} - onMoveFile={(fileId, dirId) => moveFileToDirectoryHandler(fileId, dirId)} - onMoveDir={(dirId, targetId) => moveDirectoryHandler(dirId, targetId)} - /> -
- {/if} + - {#if syncing} -
-
- -
- {syncing} + {#if sortKey} + + {/if}
- {/if} - - {#if fileItems !== null && fileItemsTotal !== null} -
-
-
-
- {#if fileItems.length > 0 || directoryItems.length > 0} -
- { - selectedFileId = fileId; - - if (fileItems) { - const file = fileItems.find((file) => file.id === selectedFileId); - if (file) { - fileSelectHandler(file); - } else { - selectedFile = null; - } - } - }} - onDelete={(fileId) => { - selectedFileId = null; - selectedFile = null; - deleteFileHandler(fileId); - }} - onRename={(fileId, name) => renameFileHandler(fileId, name)} - onNavigateDirectory={(dirId) => navigateToDirectory(dirId)} - onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)} - onDeleteDirectory={(id) => confirmDeleteDirectory(id)} - onMoveFileToDirectory={(fileId, dirId) => - moveFileToDirectoryHandler(fileId, dirId)} - onMoveDirectoryToDirectory={(dirId, targetId) => - moveDirectoryHandler(dirId, targetId)} - /> -
- - {#if fileItemsTotal > 30} - - {/if} - {:else} -
-
- {$i18n.t('No content found')} -
-
- {/if} + {#if currentDirectoryId !== null} +
+ navigateToDirectory(dirId)} + onMoveFile={(fileId, dirId) => moveFileToDirectoryHandler(fileId, dirId)} + onMoveDir={(dirId, targetId) => moveDirectoryHandler(dirId, targetId)} + /> +
+ {/if} + + {#if syncing} +
+
+ +
+ {syncing}
- - {#if selectedFileId !== null} - { - selectedFileId = null; - selectedFile = null; - }} - > -
-
-
-
- -
-
- {selectedFile?.meta?.name} + onRename={(fileId, name) => renameFileHandler(fileId, name)} + onNavigateDirectory={(dirId) => navigateToDirectory(dirId)} + onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)} + onDeleteDirectory={(id) => confirmDeleteDirectory(id)} + onMoveFileToDirectory={(fileId, dirId) => + moveFileToDirectoryHandler(fileId, dirId)} + onMoveDirectoryToDirectory={(dirId, targetId) => + moveDirectoryHandler(dirId, targetId)} + />
- {#if knowledge?.write_access} + {#if fileItemsTotal > 30} + + {/if} + {:else} +
+ {$i18n.t('No content found')} +
+
+ {/if} +
+
+
+ + {#if selectedFileId !== null} + { + selectedFileId = null; + selectedFile = null; + }} + > +
+
+
+
- {/if} -
+
+ {selectedFile?.meta?.name} +
- {#key selectedFile.id} - {/key}
From b7934e918223ec0a9e972accd647a8654e503156 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 01:45:50 -0500 Subject: [PATCH 169/346] refac --- src/lib/components/common/ToolCallDisplay.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte index db7ef971ec5..98a08440a40 100644 --- a/src/lib/components/common/ToolCallDisplay.svelte +++ b/src/lib/components/common/ToolCallDisplay.svelte @@ -84,12 +84,13 @@ } } - $: args = decode(attributes?.arguments ?? ''); export let resultContent: string = ''; $: result = resultContent || decode(attributes?.result ?? ''); $: files = parseJSONString(decode(attributes?.files ?? '')); $: embeds = parseJSONString(decode(attributes?.embeds ?? '')); + $: args = + open || (Array.isArray(embeds) && embeds.length > 0) ? decode(attributes?.arguments ?? '') : ''; $: isDone = attributes?.done === 'true'; $: isExecuting = attributes?.done && attributes?.done !== 'true'; From ce4a323f437864b7703314ae616dda164b95300e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 01:52:07 -0500 Subject: [PATCH 170/346] refac --- backend/open_webui/config.py | 9 ++ .../retrieval/loaders/microsoft_web_iq.py | 110 ++++++++++++++++++ .../retrieval/web/microsoft_web_iq.py | 60 ++++++++++ backend/open_webui/retrieval/web/utils.py | 80 ++++++++++++- backend/open_webui/routers/retrieval.py | 32 +++++ .../admin/Settings/WebSearch.svelte | 95 ++++++++++++++- 6 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 backend/open_webui/retrieval/loaders/microsoft_web_iq.py create mode 100644 backend/open_webui/retrieval/web/microsoft_web_iq.py diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 8765ccd6175..2d9cb5de8ba 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1179,6 +1179,12 @@ def reachable(host: str, port: int) -> bool: PERPLEXITY_SEARCH_API_URL = os.getenv('PERPLEXITY_SEARCH_API_URL', 'https://api.perplexity.ai/search') +MICROSOFT_WEB_IQ_API_BASE_URL = os.getenv('MICROSOFT_WEB_IQ_API_BASE_URL', 'https://api.microsoft.ai/v3') + +MICROSOFT_WEB_IQ_API_KEY = os.getenv('MICROSOFT_WEB_IQ_API_KEY', '') + +MICROSOFT_WEB_IQ_LANGUAGE = os.getenv('MICROSOFT_WEB_IQ_LANGUAGE', 'en') + SOUGOU_API_SID = os.getenv('SOUGOU_API_SID', '') SOUGOU_API_SK = os.getenv('SOUGOU_API_SK', '') @@ -2805,6 +2811,9 @@ def feishu_oauth_register(oauth: OAuth): 'rag.web.search.perplexity_model': PERPLEXITY_MODEL, 'rag.web.search.perplexity_search_context_usage': PERPLEXITY_SEARCH_CONTEXT_USAGE, 'rag.web.search.perplexity_search_api_url': PERPLEXITY_SEARCH_API_URL, + 'rag.web.search.microsoft_web_iq_api_base_url': MICROSOFT_WEB_IQ_API_BASE_URL, + 'rag.web.search.microsoft_web_iq_api_key': MICROSOFT_WEB_IQ_API_KEY, + 'rag.web.search.microsoft_web_iq_language': MICROSOFT_WEB_IQ_LANGUAGE, 'rag.web.search.sougou_api_sid': SOUGOU_API_SID, 'rag.web.search.sougou_api_sk': SOUGOU_API_SK, 'rag.web.search.tavily_api_key': TAVILY_API_KEY, diff --git a/backend/open_webui/retrieval/loaders/microsoft_web_iq.py b/backend/open_webui/retrieval/loaders/microsoft_web_iq.py new file mode 100644 index 00000000000..fdf10e63324 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/microsoft_web_iq.py @@ -0,0 +1,110 @@ +import logging +import time +from collections.abc import Iterator +from typing import Any +from urllib.parse import urlparse + +import requests +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document + +log = logging.getLogger(__name__) + +DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3' +MICROSOFT_BROWSE_RETRY_STATUS_CODES = {202, 429, 500, 502, 503, 504} +MICROSOFT_BROWSE_MAX_RETRIES = 2 + + +class MicrosoftWebIQLoader(BaseLoader): + def __init__( + self, + urls: str | list[str], + api_base_url: str, + api_key: str, + language: str = 'en', + verify_ssl: bool = True, + timeout: Any = None, + continue_on_failure: bool = True, + ) -> None: + self.urls = urls if isinstance(urls, list) else [urls] + self.api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/') + self.api_key = api_key + self.language = language + self.verify_ssl = verify_ssl + self.timeout = timeout + self.continue_on_failure = continue_on_failure + + def lazy_load(self) -> Iterator[Document]: + for url in self.urls: + try: + doc = self._browse_url(url) + if doc is not None: + yield doc + except Exception as e: + if self.continue_on_failure: + log.warning(f'Error browsing {url} with Microsoft Web IQ: {e}') + else: + raise e + + def _browse_url(self, url: str) -> Document | None: + headers = { + 'host': urlparse(self.api_base_url).netloc or 'api.microsoft.ai', + 'x-apikey': self.api_key, + 'content-type': 'application/json', + } + payload = { + 'url': url, + 'contentFormat': 'markdown', + 'liveCrawl': 'fallback', + 'renderDynamicPages': True, + 'language': self.language, + } + try: + request_timeout = float(self.timeout) + except (TypeError, ValueError): + request_timeout = 60 + request_timeout = request_timeout if request_timeout > 0 else 60 + + data: dict[str, Any] = {} + for attempt in range(MICROSOFT_BROWSE_MAX_RETRIES + 1): + response = requests.post( + f'{self.api_base_url}/browse', + json=payload, + headers=headers, + timeout=request_timeout, + verify=self.verify_ssl, + ) + + if response.status_code in MICROSOFT_BROWSE_RETRY_STATUS_CODES and attempt < MICROSOFT_BROWSE_MAX_RETRIES: + try: + body = response.json() + except Exception: + body = {} + retry_after = body.get('retryAfter') if isinstance(body, dict) else None + retry_after = retry_after or response.headers.get('Retry-After') + try: + delay = min(10.0, max(0.0, float(str(retry_after).rstrip('s')))) + except (TypeError, ValueError): + delay = min(8.0, float(2**attempt)) + log.warning( + 'Microsoft Browse %s returned HTTP %s; retrying in %.1fs', + url, + response.status_code, + delay, + ) + time.sleep(delay) + continue + + response.raise_for_status() + data = response.json() + break + + content = data.get('content') or '' + if not isinstance(content, str) or not content.strip(): + return None + + metadata = {'source': data.get('url') or url} + if data.get('title'): + metadata['title'] = data['title'] + + return Document(page_content=content, metadata=metadata) diff --git a/backend/open_webui/retrieval/web/microsoft_web_iq.py b/backend/open_webui/retrieval/web/microsoft_web_iq.py new file mode 100644 index 00000000000..c4188b684ce --- /dev/null +++ b/backend/open_webui/retrieval/web/microsoft_web_iq.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.utils.headers import include_user_info_headers + +log = logging.getLogger(__name__) + +DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3' + + +def search_microsoft_web_iq( + api_base_url: str, + api_key: str, + query: str, + count: int, + filter_list: list[str | None] | None = None, + language: str = 'en', + user=None, +) -> list[SearchResult]: + try: + api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/') + headers = { + 'host': urlparse(api_base_url).netloc or 'api.microsoft.ai', + 'x-apikey': api_key, + 'content-type': 'application/json', + } + if user is not None: + headers = include_user_info_headers(headers, user) + + response = requests.post( + f'{api_base_url}/search/web', + json={ + 'query': query, + 'maxResults': count, + 'language': language, + 'contentFormat': 'passage', + }, + headers=headers, + ) + response.raise_for_status() + + results = response.json().get('webResults', []) + if filter_list: + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result['url'], + title=result.get('title'), + snippet=result.get('content'), + ) + for result in results + ] + except Exception as e: + log.error(f'Error searching with Microsoft Web IQ API: {e}') + return [] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 327d7e27389..cb3c32f2382 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -36,6 +36,9 @@ FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, FIRECRAWL_TIMEOUT, + MICROSOFT_WEB_IQ_API_BASE_URL, + MICROSOFT_WEB_IQ_API_KEY, + MICROSOFT_WEB_IQ_LANGUAGE, PLAYWRIGHT_TIMEOUT, PLAYWRIGHT_WS_URL, TAVILY_API_KEY, @@ -52,6 +55,7 @@ USER_AGENT, ) from open_webui.retrieval.loaders.external_web import ExternalWebLoader +from open_webui.retrieval.loaders.microsoft_web_iq import MicrosoftWebIQLoader from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url from open_webui.utils.misc import is_host_allowed @@ -356,6 +360,7 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): def __init__( self, web_paths: Union[str, List[str]], + api_base_url: str, api_key: str, extract_depth: Literal['basic', 'advanced'] = 'basic', continue_on_failure: bool = True, @@ -389,6 +394,7 @@ def __init__( # Store parameters for creating TavilyLoader instances self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] + self.api_base_url = api_base_url self.api_key = api_key self.extract_depth = extract_depth self.continue_on_failure = continue_on_failure @@ -464,6 +470,67 @@ async def alazy_load(self) -> AsyncIterator[Document]: raise e +class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): + def __init__( + self, + web_paths: Union[str, List[str]], + api_key: str, + language: str = 'en', + verify_ssl: bool = True, + trust_env: bool = False, + requests_per_second: Optional[float] = None, + continue_on_failure: bool = True, + timeout: Optional[int] = None, + ): + self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] + self.api_key = api_key + self.language = language + self.verify_ssl = verify_ssl + self.trust_env = trust_env + self.requests_per_second = requests_per_second + self.last_request_time = None + self.continue_on_failure = continue_on_failure + self.timeout = timeout + + def lazy_load(self) -> Iterator[Document]: + 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') + + loader = MicrosoftWebIQLoader( + urls=valid_urls, + api_base_url=self.api_base_url, + api_key=self.api_key, + language=self.language, + verify_ssl=self.verify_ssl, + timeout=self.timeout, + continue_on_failure=self.continue_on_failure, + ) + yield from loader.lazy_load() + + async def alazy_load(self) -> AsyncIterator[Document]: + try: + docs = await run_in_threadpool(lambda: list(self.lazy_load())) + for doc in docs: + yield doc + except Exception as e: + if self.continue_on_failure: + log.warning(f'Error browsing URLs with Microsoft Web IQ: {e}') + else: + raise e + + class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin): """Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection. @@ -813,6 +880,17 @@ def get_web_loader( web_loader_args['api_key'] = TAVILY_API_KEY web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH + if WEB_LOADER_ENGINE == 'microsoft_web_iq': + WebLoaderClass = SafeMicrosoftWebIQLoader + web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL + web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY + web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE + if WEB_LOADER_TIMEOUT: + try: + web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT) + except ValueError: + pass + if WEB_LOADER_ENGINE == 'external': WebLoaderClass = ExternalWebLoader web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL @@ -831,5 +909,5 @@ def get_web_loader( else: raise ValueError( f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. ' - "Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'." + "Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'." ) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 41c9de51a87..38cddbe3bd6 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -95,6 +95,7 @@ # Web search engines from open_webui.retrieval.web.main import SearchResult +from open_webui.retrieval.web.microsoft_web_iq import search_microsoft_web_iq from open_webui.retrieval.web.mojeek import search_mojeek from open_webui.retrieval.web.ollama import search_ollama_cloud from open_webui.retrieval.web.perplexity import search_perplexity @@ -317,6 +318,9 @@ def get_rf( 'MINERU_API_URL': 'rag.mineru_api_url', 'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions', 'MINERU_PARAMS': 'rag.mineru_params', + 'MICROSOFT_WEB_IQ_API_BASE_URL': 'rag.web.search.microsoft_web_iq_api_base_url', + 'MICROSOFT_WEB_IQ_API_KEY': 'rag.web.search.microsoft_web_iq_api_key', + 'MICROSOFT_WEB_IQ_LANGUAGE': 'rag.web.search.microsoft_web_iq_language', 'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url', 'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key', 'MOJEEK_SEARCH_API_KEY': 'rag.web.search.mojeek_search_api_key', @@ -724,6 +728,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'PERPLEXITY_MODEL': config.PERPLEXITY_MODEL, 'PERPLEXITY_SEARCH_CONTEXT_USAGE': config.PERPLEXITY_SEARCH_CONTEXT_USAGE, 'PERPLEXITY_SEARCH_API_URL': config.PERPLEXITY_SEARCH_API_URL, + 'MICROSOFT_WEB_IQ_API_BASE_URL': config.MICROSOFT_WEB_IQ_API_BASE_URL, + 'MICROSOFT_WEB_IQ_API_KEY': config.MICROSOFT_WEB_IQ_API_KEY, + 'MICROSOFT_WEB_IQ_LANGUAGE': config.MICROSOFT_WEB_IQ_LANGUAGE, 'SOUGOU_API_SID': config.SOUGOU_API_SID, 'SOUGOU_API_SK': config.SOUGOU_API_SK, 'WEB_LOADER_ENGINE': config.WEB_LOADER_ENGINE, @@ -797,6 +804,9 @@ class WebConfig(BaseModel): PERPLEXITY_MODEL: str | None = None PERPLEXITY_SEARCH_CONTEXT_USAGE: str | None = None PERPLEXITY_SEARCH_API_URL: str | None = None + MICROSOFT_WEB_IQ_API_BASE_URL: str | None = None + MICROSOFT_WEB_IQ_API_KEY: str | None = None + MICROSOFT_WEB_IQ_LANGUAGE: str | None = None SOUGOU_API_SID: str | None = None SOUGOU_API_SK: str | None = None WEB_LOADER_ENGINE: str | None = None @@ -1298,6 +1308,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend config.PERPLEXITY_MODEL = form_data.web.PERPLEXITY_MODEL config.PERPLEXITY_SEARCH_CONTEXT_USAGE = form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE config.PERPLEXITY_SEARCH_API_URL = form_data.web.PERPLEXITY_SEARCH_API_URL + config.MICROSOFT_WEB_IQ_API_BASE_URL = form_data.web.MICROSOFT_WEB_IQ_API_BASE_URL + config.MICROSOFT_WEB_IQ_API_KEY = form_data.web.MICROSOFT_WEB_IQ_API_KEY + config.MICROSOFT_WEB_IQ_LANGUAGE = form_data.web.MICROSOFT_WEB_IQ_LANGUAGE config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK @@ -1326,6 +1339,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend config.LINKUP_API_KEY = form_data.web.LINKUP_API_KEY config.LINKUP_SEARCH_PARAMS = form_data.web.LINKUP_SEARCH_PARAMS + await config.save() + return { 'status': True, # RAG settings @@ -1438,6 +1453,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'PERPLEXITY_MODEL': config.PERPLEXITY_MODEL, 'PERPLEXITY_SEARCH_CONTEXT_USAGE': config.PERPLEXITY_SEARCH_CONTEXT_USAGE, 'PERPLEXITY_SEARCH_API_URL': config.PERPLEXITY_SEARCH_API_URL, + 'MICROSOFT_WEB_IQ_API_BASE_URL': config.MICROSOFT_WEB_IQ_API_BASE_URL, + 'MICROSOFT_WEB_IQ_API_KEY': config.MICROSOFT_WEB_IQ_API_KEY, + 'MICROSOFT_WEB_IQ_LANGUAGE': config.MICROSOFT_WEB_IQ_LANGUAGE, 'SOUGOU_API_SID': config.SOUGOU_API_SID, 'SOUGOU_API_SK': config.SOUGOU_API_SK, 'WEB_LOADER_ENGINE': config.WEB_LOADER_ENGINE, @@ -2394,6 +2412,20 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li model=config.PERPLEXITY_MODEL, search_context_usage=config.PERPLEXITY_SEARCH_CONTEXT_USAGE, ) + elif engine == 'microsoft_web_iq': + if config.MICROSOFT_WEB_IQ_API_KEY: + return await asyncio.to_thread( + search_microsoft_web_iq, + config.MICROSOFT_WEB_IQ_API_BASE_URL, + config.MICROSOFT_WEB_IQ_API_KEY, + query, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + config.MICROSOFT_WEB_IQ_LANGUAGE, + user, + ) + else: + raise Exception('No MICROSOFT_WEB_IQ_API_KEY found in environment variables') elif engine == 'sougou': if config.SOUGOU_API_SID and config.SOUGOU_API_SK: return await asyncio.to_thread( diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index 38938d6a72f..fdd94da220d 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -36,6 +36,7 @@ 'bing', 'exa', 'perplexity', + 'microsoft_web_iq', 'sougou', 'firecrawl', 'external', @@ -43,7 +44,7 @@ 'youcom', 'linkup' ]; - let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external']; + let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'microsoft_web_iq', 'external']; let webConfig = null; @@ -714,6 +715,51 @@
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'microsoft_web_iq'} +
+
+
+ {$i18n.t('Microsoft Web IQ API Base URL')} +
+ +
+
+ +
+
+
+ +
+
+ {$i18n.t('Microsoft Web IQ API Key')} +
+ + +
+ +
+
+ {$i18n.t('Language')} +
+ + +
+
{:else if webConfig.WEB_SEARCH_ENGINE === 'sougou'}
@@ -1211,6 +1257,53 @@
{/if}
+ {:else if webConfig.WEB_LOADER_ENGINE === 'microsoft_web_iq'} +
+ {#if webConfig.WEB_SEARCH_ENGINE !== 'microsoft_web_iq'} +
+
+ {$i18n.t('Microsoft Web IQ API Base URL')} +
+ +
+
+ +
+
+
+ +
+
+ {$i18n.t('Microsoft Web IQ API Key')} +
+ + +
+ +
+
+ {$i18n.t('Language')} +
+ + +
+ {/if} +
{:else if webConfig.WEB_LOADER_ENGINE === 'external'}
From 3c67774eb3658ff5676c47b1d738feaf8d25ca01 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:53:20 -0400 Subject: [PATCH 171/346] fix(auth): enforce settings.interface permission on /user/settings/update endpoint (#25996) --- backend/open_webui/routers/users.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 83eb25312ac..c98eb2d6da2 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -324,6 +324,14 @@ async def update_user_settings_by_session_user( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + if user.role != 'admin' and not await has_permission( + user.id, 'settings.interface', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + updated_user_settings = form_data.model_dump() ui_settings = updated_user_settings.get('ui') if ( From c8260745a6b3a9c8097740bb4ff39bf486b49b3b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 01:59:06 -0500 Subject: [PATCH 172/346] refac --- src/lib/components/chat/ShareChatModal.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte index 7802b8189cf..579b0dc3d65 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -104,7 +104,6 @@ } else { chat = null; accessGrants = []; - console.log(chat); } })(); } From f923edcaaa3965a0d2dd31fdc995aaff989a01fd Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:00:23 -0400 Subject: [PATCH 173/346] feat(ui): add Clone option to automations 3-dots menu (#25790) --- src/lib/components/AutomationModal.svelte | 13 ++++++++++++- .../automations/AutomationMenu.svelte | 14 ++++++++++++++ src/routes/(app)/automations/+page.svelte | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte index c16265515ea..f112d093c03 100644 --- a/src/lib/components/AutomationModal.svelte +++ b/src/lib/components/AutomationModal.svelte @@ -1,5 +1,5 @@ -
+
From 534206095f3712758c81c7e502e95d1601b99996 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:54:19 +0200 Subject: [PATCH 202/346] fix: route Account Pending sign out through backend signout flow (#25681) The Sign Out button on the Account Pending overlay only cleared the local token and redirected to /auth, skipping the backend signout. This left the OAuth/OIDC session alive at the IdP, so re-login bounced the user straight back into pending and never re-fetched updated role/group claims. Call userSignOut() and honor the returned redirect_url, matching the sidebar logout flow. Fixes #25644 Co-authored-by: Claude Opus 4.8 (1M context) --- src/lib/components/layout/Overlay/AccountPending.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/components/layout/Overlay/AccountPending.svelte b/src/lib/components/layout/Overlay/AccountPending.svelte index ae3b7e896b9..84f74361f87 100644 --- a/src/lib/components/layout/Overlay/AccountPending.svelte +++ b/src/lib/components/layout/Overlay/AccountPending.svelte @@ -2,7 +2,7 @@ import DOMPurify from 'dompurify'; import { marked } from 'marked'; - import { getAdminDetails } from '$lib/apis/auths'; + import { getAdminDetails, userSignOut } from '$lib/apis/auths'; import { onMount, tick, getContext } from 'svelte'; import { config } from '$lib/stores'; @@ -70,8 +70,12 @@
From 7be009649a0a94008484335c492ab0af18fde41f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 02:57:58 -0500 Subject: [PATCH 203/346] refac --- backend/open_webui/main.py | 3 ++ backend/open_webui/routers/configs.py | 4 ++- backend/open_webui/utils/oauth.py | 21 +++++++++-- src/lib/apis/configs/index.ts | 1 + src/lib/components/AddToolServerModal.svelte | 37 ++++++++++++++++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 9c28e54fca0..166ee55b3d6 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2265,6 +2265,7 @@ async def register_client(request, client_id: str) -> bool: server_url = connection.get('url') auth_type = connection.get('auth_type', 'none') + oauth_scope = (connection.get('info') or {}).get('oauth_scope') or (connection.get('config') or {}).get('oauth_scope') oauth_server_key = (connection.get('config') or {}).get('oauth_server_key') try: @@ -2288,6 +2289,7 @@ async def register_client(request, client_id: str) -> bool: server_url, oauth_client_id=oauth_client_id, oauth_client_secret=oauth_client_secret, + oauth_scope=oauth_scope, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( @@ -2295,6 +2297,7 @@ async def register_client(request, client_id: str) -> bool: client_id, server_url, oauth_server_key, + oauth_scope=oauth_scope, ) except Exception as e: log.error(f'OAuth client re-registration failed for {client_id}: {e}') diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index f8fa412ccea..83b1bb921c7 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -155,6 +155,7 @@ class OAuthClientRegistrationForm(BaseModel): client_name: str | None = None client_secret: str | None = None oauth_server_url: str | None = None + oauth_scope: str | None = None @router.post('/oauth/clients/register') @@ -179,10 +180,11 @@ async def register_oauth_client( oauth_server_url, oauth_client_id=form_data.client_id, oauth_client_secret=form_data.client_secret, + oauth_scope=form_data.oauth_scope, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, oauth_client_id, oauth_server_url + request, oauth_client_id, oauth_server_url, oauth_scope=form_data.oauth_scope ) return { 'status': True, diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 8e2b5e18255..cceb3408a77 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -465,6 +465,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( client_id: str, oauth_server_url: str, oauth_server_key: Optional[str] = None, + oauth_scope: str | None = None, ) -> OAuthClientInformationFull: try: oauth_server_metadata = None @@ -487,7 +488,10 @@ async def get_oauth_client_info_with_dynamic_client_registration( # Prefer the resource-specific scopes from the Protected Resource Metadata # (RFC 9728) over the AS's full scopes_supported catalog, for least # privilege. Mirrors the static-credentials flow (#24690). - if resource_metadata.scopes_supported: + scope_override = ' '.join(oauth_scope.replace(',', ' ').split()) if oauth_scope else None + if scope_override: + oauth_client_metadata.scope = scope_override + elif resource_metadata.scopes_supported: oauth_client_metadata.scope = ' '.join(resource_metadata.scopes_supported) discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url) @@ -587,6 +591,7 @@ async def get_oauth_client_info_with_static_credentials( oauth_server_url: str, oauth_client_id: str, oauth_client_secret: str, + oauth_scope: str | None = None, ) -> OAuthClientInformationFull: """ Build an OAuthClientInformationFull from user-provided static credentials. @@ -621,7 +626,9 @@ async def get_oauth_client_info_with_static_credentials( # Unlike the Authorization Server's scopes_supported (which is a full catalog # of every scope the server can grant), the PRM scopes_supported represents # what this specific resource requires — making it safe to request them all. - scope = ' '.join(resource_metadata.scopes_supported) if resource_metadata.scopes_supported else None + scope = (' '.join(oauth_scope.replace(',', ' ').split()) if oauth_scope else None) or ( + ' '.join(resource_metadata.scopes_supported) if resource_metadata.scopes_supported else None + ) # Determine token_endpoint_auth_method token_endpoint_auth_method = 'client_secret_post' @@ -687,10 +694,18 @@ def get_connection_oauth_resource_parameter(connection: dict) -> OAuthResourcePa def apply_connection_oauth_options(connection: dict, oauth_client_info: dict) -> dict: - return { + info = connection.get('info') or {} + config = connection.get('config') or {} + oauth_scope = info.get('oauth_scope') or config.get('oauth_scope') + oauth_scope = ' '.join(oauth_scope.replace(',', ' ').split()) if oauth_scope else None + + options = { **oauth_client_info, 'oauth_resource_parameter': get_connection_oauth_resource_parameter(connection), } + if oauth_scope: + options['scope'] = oauth_scope + return options def scope_has_resource_indicator(scope: str | None) -> bool: diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index f890b9edf2a..15f32e94ac9 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -466,6 +466,7 @@ type RegisterOAuthClientForm = { client_name?: string; client_secret?: string; oauth_server_url?: string; + oauth_scope?: string; }; export const registerOAuthClient = async ( diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 2cd17ed5bec..12670072146 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -61,6 +61,7 @@ let oauthClientId = ''; let oauthClientSecret = ''; let oauthServerUrl = ''; + let oauthScope = ''; let oauthResourceParameter = 'auto'; let enable = true; @@ -93,9 +94,11 @@ client_id: string; client_secret?: string; oauth_server_url?: string; + oauth_scope?: string; } = { url: url, client_id: id, + ...(oauthScope ? { oauth_scope: oauthScope } : {}), ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret, oauth_server_url: oauthServerUrl } : {}) @@ -226,6 +229,7 @@ id = data.info.id ?? ''; name = data.info.name ?? ''; description = data.info.description ?? ''; + oauthScope = data.info.oauth_scope ?? ''; oauthResourceParameter = data.info.oauth_resource_parameter ?? 'auto'; } @@ -262,7 +266,10 @@ name: name, description: description, ...(type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) - ? { oauth_resource_parameter: oauthResourceParameter } + ? { + ...(oauthScope ? { oauth_scope: oauthScope } : {}), + oauth_resource_parameter: oauthResourceParameter + } : {}) } } @@ -348,7 +355,10 @@ name: name, description: description, ...(type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) - ? { oauth_resource_parameter: oauthResourceParameter } + ? { + ...(oauthScope ? { oauth_scope: oauthScope } : {}), + oauth_resource_parameter: oauthResourceParameter + } : {}), ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}), ...(auth_type === 'oauth_2.1_static' @@ -385,6 +395,7 @@ oauthClientId = ''; oauthClientSecret = ''; oauthServerUrl = ''; + oauthScope = ''; oauthResourceParameter = 'auto'; enable = true; @@ -413,6 +424,7 @@ oauthClientId = connection.info?.oauth_client_id ?? ''; oauthClientSecret = connection.info?.oauth_client_secret ?? ''; oauthServerUrl = connection.info?.oauth_server_url ?? ''; + oauthScope = connection.info?.oauth_scope ?? ''; oauthResourceParameter = connection.info?.oauth_resource_parameter ?? 'auto'; enable = connection.config?.enable ?? true; @@ -885,6 +897,27 @@ {/if} {#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)} +
+
+ + +
+ +
+
+
+
+ +
@@ -373,42 +628,169 @@
{/if} - { selectedIdx = idx + actions.length; }} - on:click={async () => { - await goto(`/c/${chat.id}`); - show = false; - onClose(); - }} > -
-
- {chat?.title} + {#if editingChatId === chat.id} +
+ { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + confirmRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelRename(); + } + }} + on:blur={() => { + if (!generating) { + confirmRename(); + } + }} + />
-
-
- {$i18n.t( - dayjs(chat?.updated_at * 1000).calendar(null, { - sameDay: '[Today]', - nextDay: '[Tomorrow]', - nextWeek: 'dddd', - lastDay: '[Yesterday]', - lastWeek: '[Last] dddd', - sameElse: 'L' // use localized format, otherwise dayjs.calendar() defaults to DD/MM/YYYY - }) - )} +
+ + + +
+ {:else} +
{ + await goto(`/c/${chat.id}`); + show = false; + onClose(); + }} + > +
+ {chat?.title} +
+
+ {/if} + +
+ {#if editingChatId !== chat.id} + {#if shiftKey} + + {:else} + + {/if} + {/if} + +
+ {$i18n.t( + dayjs(chat?.updated_at * 1000).calendar(null, { + sameDay: '[Today]', + nextDay: '[Tomorrow]', + nextWeek: 'dddd', + lastDay: '[Yesterday]', + lastWeek: '[Last] dddd', + sameElse: 'L' + }) + )} +
- +
{/each} {#if !allChatsLoaded} diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 96e8a997ce9..13731e6528e 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -362,6 +362,7 @@ draggable="false" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" on:click={() => { + show = false; renameHandler(); }} > @@ -375,6 +376,7 @@ draggable="false" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" on:click={() => { + show = false; pinHandler(); }} > From 33cd199e6dffddd4ee8974af41ebb894871d74c1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 03:16:59 -0500 Subject: [PATCH 209/346] refac --- backend/open_webui/models/auths.py | 6 +++--- backend/open_webui/utils/auth.py | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 00985c97b87..6538c1dbf9a 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -148,15 +148,15 @@ async def authenticate_user( log.info('authenticate_user: %s', email) resolved = await Users.get_user_by_email(email, db=db) if not resolved: - verify_password(PLACEHOLDER_HASH) + await verify_password(PLACEHOLDER_HASH) return # load the credential row and verify the password hash async with get_async_db_context(db) as session: credential = await session.get(Auth, resolved.id) if not credential or not credential.active: - verify_password(PLACEHOLDER_HASH) + await verify_password(PLACEHOLDER_HASH) return - if not verify_password(credential.password): + if not await verify_password(credential.password): return return resolved diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index b379328db1f..2b0cfa97920 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import hashlib import hmac @@ -160,8 +161,6 @@ def nt(b): async def get_password_hash(password: str) -> str: """Hash a password using bcrypt in a thread pool (non-blocking).""" - import asyncio - return (await asyncio.to_thread(bcrypt.hashpw, password.encode('utf-8'), bcrypt.gensalt())).decode('utf-8') @@ -179,15 +178,15 @@ def validate_password(password: str) -> bool: return True -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash""" - return ( - bcrypt.checkpw( - plain_password.encode('utf-8'), - hashed_password.encode('utf-8'), - ) - if hashed_password - else None +async def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password using bcrypt in a thread pool.""" + if not hashed_password: + return False + + return await asyncio.to_thread( + bcrypt.checkpw, + plain_password.encode('utf-8'), + hashed_password.encode('utf-8'), ) From c93d4f04aad1b0d4f8a8bda7ac403b2c8ee35f38 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 03:20:02 -0500 Subject: [PATCH 210/346] refac --- src/lib/components/common/Collapsible.svelte | 24 ++++++++------------ 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/lib/components/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index a43727bb7e2..f41ade217af 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -55,7 +55,14 @@ export let onChange: Function = () => {}; - $: onChange(open); + const toggleOpen = () => { + if (disabled) { + return; + } + + open = !open; + onChange(open); + }; const collapsibleId = uuidv4(); @@ -64,14 +71,7 @@ {#if title !== null} -
{ - if (!disabled) { - open = !open; - } - }} - > +
From 6e14e446cb49e75ef7f7bcb2e747e91be29be2e1 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:20:46 -0400 Subject: [PATCH 211/346] fix(api): handle orphaned shared_chat rows when unsharing (#25632) --- backend/open_webui/routers/chats.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 537207f849a..48bd71fb9d6 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1674,11 +1674,11 @@ async def delete_shared_chat_by_id( if not chat: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) - if not chat.share_id: - return False - await SharedChats.delete_by_chat_id(id, db=db) - await Chats.update_chat_share_id_by_id(id, None, db=db) + + if chat.share_id: + await Chats.update_chat_share_id_by_id(id, None, db=db) + await AccessGrants.set_access_grants('shared_chat', id, [], db=db) await publish_event( From 5796d4436348264cae535a3a0d308acfbbe260c4 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:21:09 +0200 Subject: [PATCH 212/346] fix: defer missing-local-embedding error so it can't crash boot (#25683) get_embedding_function runs at import time (main.py), so the empty-engine + no-model guard added in 55ca719b raised ValueError during startup and bricked the instance: a blank embedding model set via the UI made the app unbootable, with no way back into settings to undo it. Move the check into the returned coroutine so construction always succeeds and the error only fires when something actually embeds, surfacing as a clear request error instead of the old cryptic 'NoneType has no attribute encode'. Fixes #25634 Fixes #25165 Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/retrieval/utils.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 04217878199..5ba010c4454 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1089,15 +1089,15 @@ def get_embedding_function( concurrent_requests=0, ) -> Awaitable: if embedding_engine == '': - if embedding_function is None: - raise ValueError( - 'No embedding model is loaded. Set RAG_EMBEDDING_MODEL to a valid ' - 'SentenceTransformer model name, or configure an external ' - 'RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).' - ) - # Sentence transformers: CPU-bound sync operation async def async_embedding_function(query, prefix=None, user=None): + # Deferred so a missing local model degrades RAG instead of crashing boot. + if embedding_function is None: + raise ValueError( + 'No embedding model is loaded. Set RAG_EMBEDDING_MODEL to a valid ' + 'SentenceTransformer model name, or configure an external ' + 'RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).' + ) return await asyncio.to_thread( ( lambda query, prefix=None: embedding_function.encode( From 78d276b4ffef04882ab8ae181941e49e4c426fd5 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:21:31 -0400 Subject: [PATCH 213/346] fix(ui): update groups count to reflect filtered search results (#25689) --- src/lib/components/admin/Users/Groups.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index f02adf8fb32..3ea6eccc2a9 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -127,7 +127,7 @@
- {groups.length} + {filteredGroups.length}
From 3fd0384ffcd0eddd6f4c688475f8ad5d3b4de510 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 03:21:41 -0500 Subject: [PATCH 214/346] refac --- backend/open_webui/routers/retrieval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 38cddbe3bd6..ec19922fa96 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -884,7 +884,7 @@ class ConfigForm(BaseModel): MINERU_API_MODE: str | None = None MINERU_API_URL: str | None = None MINERU_API_KEY: str | None = None - MINERU_API_TIMEOUT: str | None = None + MINERU_API_TIMEOUT: int | None = None MINERU_PARAMS: dict | None = None MINERU_FILE_EXTENSIONS: list[str] | None = None From 70b89d01c2bbc7c89ba8a9dabe31aea6312e6994 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:22:33 -0400 Subject: [PATCH 215/346] feat(ui): add drag-to-reorder for pinned notes in sidebar (#25677) --- src/lib/components/layout/Sidebar.svelte | 47 +-------- .../layout/Sidebar/PinnedNoteList.svelte | 99 +++++++++++++++++++ src/lib/stores/index.ts | 1 + 3 files changed, 102 insertions(+), 45 deletions(-) create mode 100644 src/lib/components/layout/Sidebar/PinnedNoteList.svelte diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 0494281e325..70a2db0d246 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -78,6 +78,7 @@ import FolderModal from './Sidebar/Folders/FolderModal.svelte'; import Sidebar from '../icons/Sidebar.svelte'; import PinnedModelList from './Sidebar/PinnedModelList.svelte'; + import PinnedNoteList from './Sidebar/PinnedNoteList.svelte'; import Note from '../icons/Note.svelte'; import Code from '../icons/Code.svelte'; import { slide } from 'svelte/transition'; @@ -1257,51 +1258,7 @@ }} onAddLabel={$i18n.t('New Note')} > - + {/if} diff --git a/src/lib/components/layout/Sidebar/PinnedNoteList.svelte b/src/lib/components/layout/Sidebar/PinnedNoteList.svelte new file mode 100644 index 00000000000..f9eb77a3c32 --- /dev/null +++ b/src/lib/components/layout/Sidebar/PinnedNoteList.svelte @@ -0,0 +1,99 @@ + + +
+ {#each sortedPinnedNotes as note (note.id)} + +
+ { + selectedChatId = null; + chatId.set(''); + if ($mobile) { + showSidebar.set(false); + } + }} + draggable="false" + > +
+ +
+
+ {note.title} +
+
+ +
+ {/each} +
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 5910a98495b..381e14048a7 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -234,6 +234,7 @@ type Settings = { renderMarkdownInAssistantMessages?: boolean; recentEmojis?: string[]; pinnedMenuItems?: string[]; + pinnedNotesOrder?: string[]; system?: string; seed?: number; From f85e906dec82344f9f109054d5bdd27f70f91021 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 03:28:50 -0500 Subject: [PATCH 216/346] refac --- src/lib/components/layout/SearchModal.svelte | 36 +++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index de81b379abb..7d007a6fed2 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -630,7 +630,7 @@
{/if} -
+
+
+ {$i18n.t( + dayjs(chat?.updated_at * 1000).calendar(null, { + sameDay: '[Today]', + nextDay: '[Tomorrow]', + nextWeek: 'dddd', + lastDay: '[Yesterday]', + lastWeek: '[Last] dddd', + sameElse: 'L' + }) + )} +
+ {#if editingChatId !== chat.id} {#if shiftKey} - {/each} From ed2d7d4acdf170540bdf9ddc771eb3a6c47ee4a6 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:30:40 -0400 Subject: [PATCH 217/346] fix(i18n): standardize title case for settings toggle labels (#25765) --- src/lib/components/chat/Settings/Audio.svelte | 2 +- src/lib/components/chat/Settings/Interface.svelte | 10 +++++----- src/lib/i18n/locales/ar-BH/translation.json | 12 ++++++------ src/lib/i18n/locales/ar/translation.json | 12 ++++++------ src/lib/i18n/locales/az-AZ/translation.json | 12 ++++++------ src/lib/i18n/locales/bg-BG/translation.json | 12 ++++++------ src/lib/i18n/locales/bn-BD/translation.json | 12 ++++++------ src/lib/i18n/locales/bo-TB/translation.json | 12 ++++++------ src/lib/i18n/locales/bs-BA/translation.json | 12 ++++++------ src/lib/i18n/locales/ca-ES/translation.json | 12 ++++++------ src/lib/i18n/locales/ceb-PH/translation.json | 12 ++++++------ src/lib/i18n/locales/cs-CZ/translation.json | 12 ++++++------ src/lib/i18n/locales/da-DK/translation.json | 12 ++++++------ src/lib/i18n/locales/de-DE/translation.json | 12 ++++++------ src/lib/i18n/locales/dg-DG/translation.json | 12 ++++++------ src/lib/i18n/locales/el-GR/translation.json | 12 ++++++------ src/lib/i18n/locales/en-GB/translation.json | 12 ++++++------ src/lib/i18n/locales/en-US/translation.json | 12 ++++++------ src/lib/i18n/locales/es-ES/translation.json | 12 ++++++------ src/lib/i18n/locales/et-EE/translation.json | 12 ++++++------ src/lib/i18n/locales/eu-ES/translation.json | 12 ++++++------ src/lib/i18n/locales/fa-IR/translation.json | 12 ++++++------ src/lib/i18n/locales/fi-FI/translation.json | 12 ++++++------ src/lib/i18n/locales/fil-PH/translation.json | 12 ++++++------ src/lib/i18n/locales/fr-CA/translation.json | 12 ++++++------ src/lib/i18n/locales/fr-FR/translation.json | 12 ++++++------ src/lib/i18n/locales/gl-ES/translation.json | 12 ++++++------ src/lib/i18n/locales/he-IL/translation.json | 12 ++++++------ src/lib/i18n/locales/hi-IN/translation.json | 12 ++++++------ src/lib/i18n/locales/hr-HR/translation.json | 12 ++++++------ src/lib/i18n/locales/hu-HU/translation.json | 12 ++++++------ src/lib/i18n/locales/id-ID/translation.json | 12 ++++++------ src/lib/i18n/locales/ie-GA/translation.json | 12 ++++++------ src/lib/i18n/locales/it-IT/translation.json | 12 ++++++------ src/lib/i18n/locales/ja-JP/translation.json | 12 ++++++------ src/lib/i18n/locales/ka-GE/translation.json | 12 ++++++------ src/lib/i18n/locales/kab-DZ/translation.json | 12 ++++++------ src/lib/i18n/locales/ko-KR/translation.json | 12 ++++++------ src/lib/i18n/locales/lt-LT/translation.json | 12 ++++++------ src/lib/i18n/locales/lv-LV/translation.json | 12 ++++++------ src/lib/i18n/locales/ms-MY/translation.json | 12 ++++++------ src/lib/i18n/locales/nb-NO/translation.json | 12 ++++++------ src/lib/i18n/locales/nl-NL/translation.json | 12 ++++++------ src/lib/i18n/locales/pa-IN/translation.json | 12 ++++++------ src/lib/i18n/locales/pl-PL/translation.json | 12 ++++++------ src/lib/i18n/locales/pt-BR/translation.json | 12 ++++++------ src/lib/i18n/locales/pt-PT/translation.json | 12 ++++++------ src/lib/i18n/locales/ro-RO/translation.json | 12 ++++++------ src/lib/i18n/locales/ru-RU/translation.json | 12 ++++++------ src/lib/i18n/locales/sk-SK/translation.json | 12 ++++++------ src/lib/i18n/locales/sr-RS/translation.json | 12 ++++++------ src/lib/i18n/locales/sv-SE/translation.json | 12 ++++++------ src/lib/i18n/locales/ta-IN/translation.json | 12 ++++++------ src/lib/i18n/locales/th-TH/translation.json | 12 ++++++------ src/lib/i18n/locales/tk-TM/translation.json | 12 ++++++------ src/lib/i18n/locales/tr-TR/translation.json | 12 ++++++------ src/lib/i18n/locales/ug-CN/translation.json | 12 ++++++------ src/lib/i18n/locales/uk-UA/translation.json | 12 ++++++------ src/lib/i18n/locales/ur-PK/translation.json | 12 ++++++------ src/lib/i18n/locales/uz-Cyrl-UZ/translation.json | 12 ++++++------ src/lib/i18n/locales/uz-Latn-Uz/translation.json | 12 ++++++------ src/lib/i18n/locales/vi-VN/translation.json | 12 ++++++------ src/lib/i18n/locales/zh-CN/translation.json | 12 ++++++------ src/lib/i18n/locales/zh-TW/translation.json | 12 ++++++------ 64 files changed, 378 insertions(+), 378 deletions(-) diff --git a/src/lib/components/chat/Settings/Audio.svelte b/src/lib/components/chat/Settings/Audio.svelte index a638e9ddfae..17e8e3c08c3 100644 --- a/src/lib/components/chat/Settings/Audio.svelte +++ b/src/lib/components/chat/Settings/Audio.svelte @@ -279,7 +279,7 @@ {/if}
-
{$i18n.t('Auto-playback response')}
+
{$i18n.t('Auto-Playback Response')}
{#if open} -
+
{#each items as item}
+
+
+
+ {$i18n.t('Show Files on Terminal Select')} +
+ +
+ { + saveSettings({ showFilesOnTerminalSelect }); + }} + /> +
+
+
+
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 381e14048a7..ae6eede3924 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -213,6 +213,7 @@ type Settings = { iframeSandboxAllowForms?: boolean; iframeSandboxAllowSameOrigin?: boolean; scrollOnBranchChange?: boolean; + showFilesOnTerminalSelect?: boolean; directConnections?: null; chatBubble?: boolean; copyFormatted?: boolean; From 7b1aa749eb0590a387ab51a12ff126868f7216a0 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:46:24 -0400 Subject: [PATCH 226/346] perf(retrieval): make URL fetch and file hash non-blocking with asyncio.to_thread (#25822) - Wrap get_content_from_url() in asyncio.to_thread() inside get_sources_from_items() to prevent sync requests.get() from blocking the event loop when users attach URL sources to chat messages. The same function was already properly wrapped at retrieval.py:1839. - Wrap hashlib.sha256(contents).hexdigest() in asyncio.to_thread() inside upload_file_handler() to prevent CPU-bound hashing from blocking the event loop during file uploads (44ms/100MB, scales linearly with file size). --- backend/open_webui/routers/files.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 26114a8276a..ba0a878f86f 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -341,7 +341,9 @@ async def upload_file_handler( # SHA-256 of raw uploaded bytes for incremental sync diffing. # If the client pre-computed and sent file_hash, use that. - file_hash = file_metadata.get('file_hash') or hashlib.sha256(contents).hexdigest() + file_hash = file_metadata.get('file_hash') or await asyncio.to_thread( + lambda: hashlib.sha256(contents).hexdigest() + ) file_item = await Files.insert_new_file( user.id, From a66477b7104c5d141ce7bffaea424b43e7666ef1 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:46:50 +0200 Subject: [PATCH 227/346] fix: bind channel thread parent/reply to the URL channel (#25766) GET /api/v1/channels/{id}/messages/{message_id}/thread authorized only the URL channel, but get_messages_by_parent_id() appended the thread parent (loaded by id) without checking it belonged to that channel, so a caller could read a message from a channel they cannot access by passing its id as the thread root. Require the parent to be in the requested channel before returning it, and reject a posted parent_id/reply_to_id that does not belong to the channel. Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/models/messages.py | 3 ++- backend/open_webui/routers/channels.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 304276b3d92..0b6c4e080b0 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -328,7 +328,8 @@ async def get_messages_by_parent_id( async with get_async_db_context(db) as db: message = await db.get(Message, parent_id) - if not message: + # Thread parent must belong to the requested channel; never disclose a foreign-channel message. + if not message or message.channel_id != channel_id: return [] result = await db.execute( diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 7a509b6b094..f3f6fd2885f 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1129,6 +1129,13 @@ async def new_message_handler(request: Request, id: str, form_data: MessageForm, ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Thread parent / reply target must belong to this channel (no cross-channel binding). + for ref_id in (form_data.parent_id, form_data.reply_to_id): + if ref_id: + ref = await Messages.get_message_by_id(ref_id, include_thread_replies=False, db=db) + if not ref or ref.channel_id != channel.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + try: message = await Messages.insert_new_message(form_data, channel.id, user.id, db=db) if message: From b05be8a9073653c296fe177b1b20183d3fefc5e7 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:48:22 -0400 Subject: [PATCH 228/346] fix(notes): refresh pinned notes sidebar when a note is deleted (#25640) --- src/lib/components/notes/NoteEditor.svelte | 1 + src/lib/components/notes/Notes.svelte | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index 3401a8d6f2d..ba3acca1072 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -622,6 +622,7 @@ ${content} }); if (res) { + pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); toast.success($i18n.t('Note deleted successfully')); goto('/notes'); } else { diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 64ffaee1833..e8ae52ddc1d 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -111,6 +111,7 @@ }); if (res) { + pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); init(); } }; From ee11069ef2a7237aa5ffee5dec95d9cb44ff35e3 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:48:48 -0400 Subject: [PATCH 229/346] perf(ollama): offload multi-GB model file I/O with asyncio.to_thread (#25829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ollama model upload and download handlers perform three stages of sync file I/O on multi-GB model files inside async handlers: 1. Persist upload — file.file.read() + write() in a loop 2. SHA-256 hash — calculate_sha256() reads the file sequentially 3. Read for blob push — open().read() loads entire model into memory All three stages block the event loop for the duration of the I/O. For a 4GB model, each stage freezes the event loop for 10+ seconds while every other user's request stalls. Wrap each blocking stage in asyncio.to_thread() in both handlers: - upload_model(): persist upload, calculate_sha256, blob read - download_file_stream(): calculate_sha256, blob read Benchmark (full pipeline: write + SHA-256 + read back, 3 trials): - 200MB: max jitter 145ms → 1ms (145x improvement) - 500MB: max jitter 1,026ms → 1ms (1,026x improvement) File I/O is pure I/O — no GIL contention. asyncio.to_thread() completely eliminates event loop blocking. --- backend/open_webui/routers/ollama.py | 34 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 60fb82b87cf..238a267e7e1 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1443,10 +1443,13 @@ async def download_file_stream( if done: f.close() - hashed = calculate_sha256(file_path, chunk_size) + hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) - with open(file_path, 'rb') as blob_f: - blob_data = blob_f.read() + def _read_blob(): + with open(file_path, 'rb') as blob_f: + return blob_f.read() + + blob_data = await asyncio.to_thread(_read_blob) blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' async with session.post( @@ -1507,12 +1510,16 @@ async def upload_model( # Stage 1: persist the uploaded file to disk chunk_size = 1024 * 1024 * 2 # 2 MiB - with open(file_path, 'wb') as out_f: - while True: - chunk = file.file.read(chunk_size) - if not chunk: - break - out_f.write(chunk) + + def _persist_upload(): + with open(file_path, 'wb') as out_f: + while True: + chunk = file.file.read(chunk_size) + if not chunk: + break + out_f.write(chunk) + + await asyncio.to_thread(_persist_upload) async def file_process_stream(): nonlocal ollama_url @@ -1520,7 +1527,7 @@ async def file_process_stream(): log.info(f'Total Model Size: {total_size}') # Stage 2: hash the file and emit SSE progress - file_hash = calculate_sha256(file_path, chunk_size) + file_hash = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) log.info(f'Model Hash: {file_hash}') try: @@ -1532,8 +1539,11 @@ async def file_process_stream(): yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n' # Stage 3: push blob to Ollama - with open(file_path, 'rb') as f: - blob_data = f.read() + def _read_blob(): + with open(file_path, 'rb') as f: + return f.read() + + blob_data = await asyncio.to_thread(_read_blob) session = await get_session() blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}' From 0fc630b34b2899599dabffffa012afd47599aa75 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:51:02 +0200 Subject: [PATCH 230/346] fix: per-user model cache used static key= (cross-user model exposure) (#25783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routers/openai.py and routers/ollama.py decorated get_all_models with `@cached(key=lambda _, user: ...)`. In aiocache 0.12, `key=` is a STATIC cache key: get_cache_key returns `self.key` verbatim (the lambda object) without calling it, so every caller collides to ONE shared entry within the TTL. The intended per-user namespacing never happened — one user's permission-filtered model list could be served to another user (or an anonymous caller) during the cache window. The per-call hook is `key_builder=` (called as key_builder(func, *args, **kwargs)). Switch both sites to key_builder with a (func, request, user=None) signature so the key is built per call. user=None mirrors ollama's optional-user signature and stays correct whether user is passed positionally, as a kwarg, or omitted. Verified offline: old form returns the same key object for distinct users (collision); new form yields distinct openai_all_models_ / ollama_all_models_ keys, and the unauthenticated base key when no user. These two were the only @cached(key=lambda ...) sites in the backend. --- backend/open_webui/routers/ollama.py | 4 +++- backend/open_webui/routers/openai.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 238a267e7e1..da3060ae7be 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -336,7 +336,9 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict: @cached( ttl=MODELS_CACHE_TTL, - key=lambda _, user: f'ollama_all_models_{user.id}' if user else 'ollama_all_models', + # key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a + # static key, so a `key=lambda` collapsed every caller to one shared entry. + key_builder=lambda _func, request, user=None: f'ollama_all_models_{user.id}' if user else 'ollama_all_models', ) async def get_all_models(request: Request, user: UserModel | None = None): """Aggregate model tags from every enabled Ollama backend.""" diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 2631011a5b3..a30e24d8e48 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -522,7 +522,9 @@ async def get_filtered_models(models, user, db=None): @cached( ttl=MODELS_CACHE_TTL, - key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models', + # key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a + # static key, so a `key=lambda` collapsed every caller to one shared entry. + key_builder=lambda _func, request, user=None: f'openai_all_models_{user.id}' if user else 'openai_all_models', ) async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: log.info('get_all_models()') From 4c05abbe59504fbec36374f6714d52909dc074ad Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:51:26 +0200 Subject: [PATCH 231/346] fix: respect model visibility in workspace base model selector (#25668) The base model selector in the model creation workspace listed every model from the global store, ignoring the per-model visibility (hidden) flag. Models marked as not visible were correctly hidden from the chat model selector but still appeared in the workspace 'Base Model (From)' dropdown. Filter out hidden models (info.meta.hidden) to match the chat selector behavior, while still keeping a model visible if it is the currently selected base, so editing a preset built on a now-hidden base does not show a blank dropdown. Fixes #25665 https://claude.ai/code/session_01CUvzYN9DjvwTpNCT5QL33B Co-authored-by: Claude --- src/lib/components/workspace/Models/ModelEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 9c41aff0dd5..27cf77aa46a 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -605,7 +605,7 @@ - {#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false)) as model} + {#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false) && (!(m?.info?.meta?.hidden ?? false) || m.id === info.base_model_id)) as model} {/each} From 33b91bd8ae8a100a5a306c91441a7d0b422c4cde Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 03:58:00 -0500 Subject: [PATCH 232/346] refac --- backend/open_webui/routers/terminals.py | 4 ++-- backend/open_webui/socket/main.py | 25 ++++++++++++++++++++----- backend/open_webui/utils/auth.py | 10 +++++----- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index e1236b08b0f..3d637010fdd 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -211,7 +211,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): import asyncio import json - from open_webui.utils.auth import decode_token + from open_webui.utils.auth import decode_token, is_valid_token # First-message authentication try: @@ -222,7 +222,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): return None token = payload.get('token', '') data = decode_token(token) - if data is None or 'id' not in data: + if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)): await ws.close(code=4001, reason='Invalid token') return None user = await Users.get_user_by_id(data['id']) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index ce95cbeb6bb..1cdd064b3a2 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -38,7 +38,7 @@ from open_webui.socket.utils import RedisDict, RedisLock, YdocManager from open_webui.tasks import create_task, stop_item_tasks from open_webui.utils.access_control import has_permission -from open_webui.utils.auth import decode_token +from open_webui.utils.auth import decode_token, is_valid_token from open_webui.utils.redis import ( build_sentinel_url, get_redis_connection, @@ -342,9 +342,12 @@ async def usage(sid, data): async def connect(sid, environ, auth): user = None if auth and 'token' in auth: + scope = (environ or {}).get('asgi.scope') or {} + fastapi_app = scope.get('app') + redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS data = decode_token(auth['token']) - if data is not None and 'id' in data: + if data is not None and 'id' in data and await is_valid_token(data, redis): user = await Users.get_user_by_id(data['id']) if user: @@ -369,8 +372,12 @@ async def user_join(sid, data): if not auth or 'token' not in auth: return + environ = sio.get_environ(sid) or {} + scope = environ.get('asgi.scope') or {} + fastapi_app = scope.get('app') + redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS token_data = decode_token(auth['token']) - if token_data is None or 'id' not in token_data: + if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis): return user = await Users.get_user_by_id(token_data['id']) @@ -416,8 +423,12 @@ async def join_channel(sid, data): if not auth or 'token' not in auth: return + environ = sio.get_environ(sid) or {} + scope = environ.get('asgi.scope') or {} + fastapi_app = scope.get('app') + redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS data = decode_token(auth['token']) - if data is None or 'id' not in data: + if data is None or 'id' not in data or not await is_valid_token(data, redis): return user = await Users.get_user_by_id(data['id']) @@ -438,8 +449,12 @@ async def join_note(sid, data): if not auth or 'token' not in auth: return + environ = sio.get_environ(sid) or {} + scope = environ.get('asgi.scope') or {} + fastapi_app = scope.get('app') + redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS token_data = decode_token(auth['token']) - if token_data is None or 'id' not in token_data: + if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis): return user = await Users.get_user_by_id(token_data['id']) diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 282e0ccf5b5..c95e23bb855 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -237,25 +237,25 @@ def decode_token(token: str) -> dict | None: return None -async def is_valid_token(request, decoded) -> bool: +async def is_valid_token(decoded, redis=None) -> bool: """ Check whether a JWT has been revoked. Two mechanisms: 1. Per-token (jti) — used by user-initiated sign-out (known jti). 2. Per-user (revoked_at) — used by OIDC back-channel logout when individual jti values are unknown; rejects tokens with iat <= revoked_at. """ - if request.app.state.redis: + if redis: # Per-token revocation jti = decoded.get('jti') if jti: - revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked') + revoked = await redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked') if revoked: return False # Per-user revocation (OIDC back-channel logout) user_id = decoded.get('id') if user_id: - revoked_at = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') + revoked_at = await redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') if revoked_at: try: revoked_at_ts = int(revoked_at) @@ -365,7 +365,7 @@ async def get_current_user( ) if data is not None and 'id' in data: - if data.get('jti') and not await is_valid_token(request, data): + if not await is_valid_token(data, getattr(request.app.state, 'redis', None)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid token', From c4688b958d7f7929f5f4303493ca311c2c121683 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:03:06 -0500 Subject: [PATCH 233/346] refac --- backend/open_webui/utils/tools.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index a727f40b915..9aba0b57e6d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -15,9 +15,7 @@ Callable, Optional, Type, - Union, get_args, - get_origin, get_type_hints, ) from urllib.parse import quote, urlencode @@ -171,6 +169,28 @@ async def get_async_tool_function_and_apply_extra_params( function: Callable, extra_params: dict ) -> Callable[..., Awaitable]: sig = inspect.signature(function) + try: + type_hints = get_type_hints(function) + except Exception: + type_hints = {} + + def coerce_kwargs(kwargs): + for name, value in kwargs.items(): + if name not in sig.parameters or value is None: + continue + + annotation = type_hints.get(name, sig.parameters[name].annotation) + args = set(get_args(annotation)) + if isinstance(value, str) and (annotation is int or args == {int, type(None)}): + kwargs[name] = int(value) + elif ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and (annotation is str or args == {str, type(None)}) + ): + kwargs[name] = str(value) + return kwargs + extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} partial_func = partial(function, **extra_params) @@ -190,12 +210,12 @@ async def get_async_tool_function_and_apply_extra_params( # wrap the functools.partial as python-genai has trouble with it # https://github.com/googleapis/python-genai/issues/907 async def new_function(*args, **kwargs): - return await partial_func(*args, **kwargs) + return await partial_func(*args, **coerce_kwargs(kwargs)) else: # Make it a coroutine function when it is not already async def new_function(*args, **kwargs): - return partial_func(*args, **kwargs) + return partial_func(*args, **coerce_kwargs(kwargs)) update_wrapper(new_function, function) new_function.__signature__ = new_sig From d3676b4f71bfdbaf4e4d76943c51117e18932ccf Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:11:46 -0500 Subject: [PATCH 234/346] refac --- backend/open_webui/routers/files.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index ba0a878f86f..a90a0c53c4c 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -338,6 +338,13 @@ async def upload_file_handler( 'OpenWebUI-File-Id': id, }, ) + max_size = await Config.get('rag.file.max_size') + if max_size and len(contents) > int(max_size) * 1024 * 1024: + await asyncio.to_thread(Storage.delete_file, file_path) + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size} MB'), + ) # SHA-256 of raw uploaded bytes for incremental sync diffing. # If the client pre-computed and sent file_hash, use that. @@ -666,6 +673,12 @@ async def update_file_data_content_by_id( ) if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db): + max_size = await Config.get('rag.file.max_size') + if max_size and len(form_data.content.encode('utf-8')) > int(max_size) * 1024 * 1024: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size} MB'), + ) try: await process_file( request, From b58b0ea7ca849b89d217e1077498a8a3fc92471f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:13:17 -0500 Subject: [PATCH 235/346] refac --- src/lib/components/common/Tags.svelte | 144 +++++++++++++++--- .../workspace/Models/ModelEditor.svelte | 13 ++ 2 files changed, 138 insertions(+), 19 deletions(-) diff --git a/src/lib/components/common/Tags.svelte b/src/lib/components/common/Tags.svelte index 65340f33203..1203feba9ef 100644 --- a/src/lib/components/common/Tags.svelte +++ b/src/lib/components/common/Tags.svelte @@ -1,25 +1,81 @@ + +
{#if !disabled} - { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - addTag(); - } - }} - /> +
+ 0} + autocomplete="off" + on:focus={() => { + suggestionsOpen = true; + positionPopup(); + }} + on:input={() => { + suggestionsOpen = true; + positionPopup(); + }} + on:keydown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + addTag(); + } else if (event.key === 'Escape') { + suggestionsOpen = false; + } + }} + on:blur={() => { + setTimeout(() => { + if (!popupElement?.contains(document.activeElement)) { + suggestionsOpen = false; + } + }, 0); + }} + /> + + {#if suggestionsOpen && filteredSuggestionTags.length > 0} +
+ {#each filteredSuggestionTags as tag (tag)} + + {/each} +
+ {/if} +
{/if}
diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 27cf77aa46a..24bbc75edf5 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -9,6 +9,7 @@ import { getSkills } from '$lib/apis/skills'; import { getFunctions } from '$lib/apis/functions'; import { getModelsDefaults } from '$lib/apis/configs'; + import { getBaseModelTags, getModelTags } from '$lib/apis/models'; import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; import Tags from '$lib/components/common/Tags.svelte'; @@ -106,6 +107,14 @@ let accessGrants = []; let terminalId = ''; let tts = { voice: '' }; + export let suggestionTags: { name: string }[] = []; + + const loadSuggestionTags = async () => { + const res: string[] = await (preset ? getModelTags : getBaseModelTags)( + localStorage.token + ).catch(() => []); + suggestionTags = res.map((tag) => ({ name: tag })); + }; const submitHandler = async () => { loading = true; @@ -255,6 +264,9 @@ if (!$functions) { await functions.set(await getFunctions(localStorage.token)); } + if (suggestionTags.length === 0) { + await loadSuggestionTags(); + } // Fetch admin-configured default model metadata so the editor // reflects the actual defaults rather than hardcoded values @@ -651,6 +663,7 @@
{ const tagName = e.detail; info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName); From ab84bbf08c5935f1a19044ef581986f83311da8b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:19:33 -0500 Subject: [PATCH 236/346] refac --- backend/open_webui/config.py | 2 ++ backend/open_webui/models/knowledge.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 2d9cb5de8ba..a2775104545 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -916,6 +916,8 @@ def reachable(host: str, port: int) -> bool: RAG_FILE_MAX_SIZE = int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None +RAG_FILE_CONTENT_SEARCH_MAX_CHARS = int(os.getenv('RAG_FILE_CONTENT_SEARCH_MAX_CHARS', str(64 * 1024 * 1024))) + FILE_IMAGE_COMPRESSION_WIDTH = int(os.getenv('FILE_IMAGE_COMPRESSION_WIDTH')) if os.getenv('FILE_IMAGE_COMPRESSION_WIDTH') else None FILE_IMAGE_COMPRESSION_HEIGHT = int(os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT')) if os.getenv('FILE_IMAGE_COMPRESSION_HEIGHT') else None diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 9d49aa7cbff..f6650e22584 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -4,6 +4,7 @@ import uuid from typing import Optional +from open_webui.config import RAG_FILE_CONTENT_SEARCH_MAX_CHARS from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.files import ( @@ -381,6 +382,7 @@ async def search_knowledge_files( # to avoid PostgreSQL "invalid memory alloc request # size" on large extracted-content rows (#24670). content_text = File.data['content'].as_string() + content_text = func.substr(content_text, 1, RAG_FILE_CONTENT_SEARCH_MAX_CHARS) search_filter = or_( File.filename.ilike(f'%{q}%'), content_text.ilike(f'%{q}%'), @@ -573,6 +575,7 @@ async def search_files_by_id( # to avoid PostgreSQL memory allocation failures on # large content (#24670). content_text = File.data['content'].as_string() + content_text = func.substr(content_text, 1, RAG_FILE_CONTENT_SEARCH_MAX_CHARS) stmt = stmt.filter( or_( File.filename.ilike(f'%{query_key}%'), From ea6d02da0fb1ae337001c14377a5c64999f7a5ca Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:25:21 -0400 Subject: [PATCH 237/346] fix(ui): use correct field name for prompt clone title suffix (#25800) --- src/lib/components/workspace/Prompts.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index a48a3ed0cbf..31664ab8bc4 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -132,7 +132,7 @@ const cloneHandler = async (prompt) => { const clonedPrompt = { ...prompt }; - clonedPrompt.title = `${clonedPrompt.title} (Clone)`; + clonedPrompt.name = `${clonedPrompt.name} (Clone)`; const baseCommand = clonedPrompt.command.startsWith('/') ? clonedPrompt.command.substring(1) : clonedPrompt.command; From fd7a59d37a36657e0aa408bb86e11e8610d57d4f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:27:48 -0500 Subject: [PATCH 238/346] refac --- backend/open_webui/routers/files.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index a90a0c53c4c..a7f29c8f962 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -1,4 +1,5 @@ import asyncio +import errno import hashlib import json import logging @@ -323,21 +324,25 @@ async def upload_file_handler( detail=ERROR_MESSAGES.DEFAULT(f'File type {file_extension} is not allowed'), ) - # replace filename with uuid + # Prefer readable storage names for admins, but fall back if the filesystem rejects it. id = str(uuid.uuid4()) name = filename filename = f'{id}_{filename}' - contents, file_path = await asyncio.to_thread( - Storage.upload_file, - file.file, - filename, - { - 'OpenWebUI-User-Email': user.email, - 'OpenWebUI-User-Id': user.id, - 'OpenWebUI-User-Name': user.name, - 'OpenWebUI-File-Id': id, - }, - ) + tags = { + 'OpenWebUI-User-Email': user.email, + 'OpenWebUI-User-Id': user.id, + 'OpenWebUI-User-Name': user.name, + 'OpenWebUI-File-Id': id, + } + try: + contents, file_path = await asyncio.to_thread(Storage.upload_file, file.file, filename, tags) + except OSError as e: + if e.errno != errno.ENAMETOOLONG: + raise + + file.file.seek(0) + filename = f'{id}.{file_extension}' if file_extension else id + contents, file_path = await asyncio.to_thread(Storage.upload_file, file.file, filename, tags) max_size = await Config.get('rag.file.max_size') if max_size and len(contents) > int(max_size) * 1024 * 1024: await asyncio.to_thread(Storage.delete_file, file_path) From f8ec63203c4408c46bb06698ae624d17b01b9301 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:31:43 -0500 Subject: [PATCH 239/346] refac --- backend/open_webui/routers/files.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index a7f29c8f962..06693c29fbe 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -338,11 +338,22 @@ async def upload_file_handler( contents, file_path = await asyncio.to_thread(Storage.upload_file, file.file, filename, tags) except OSError as e: if e.errno != errno.ENAMETOOLONG: - raise + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e.strerror or 'Error uploading file'), + ) file.file.seek(0) filename = f'{id}.{file_extension}' if file_extension else id - contents, file_path = await asyncio.to_thread(Storage.upload_file, file.file, filename, tags) + try: + contents, file_path = await asyncio.to_thread(Storage.upload_file, file.file, filename, tags) + except OSError as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e.strerror or 'Error uploading file'), + ) max_size = await Config.get('rag.file.max_size') if max_size and len(contents) > int(max_size) * 1024 * 1024: await asyncio.to_thread(Storage.delete_file, file_path) From 718b226177e580abc074e7917ad993700ec41f83 Mon Sep 17 00:00:00 2001 From: Alberto de la Cruz Date: Mon, 29 Jun 2026 11:34:36 +0200 Subject: [PATCH 240/346] fix: discover MCP Protected Resource Metadata when server does not return 401 (#25980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_protected_resource_metadata() only attempted RFC 9728 discovery when the anonymous `initialize` probe returned 401 with a WWW-Authenticate header. Some remote MCP servers — notably Google's Gmail/Drive/Calendar MCPs (gmailmcp.googleapis.com, etc.) — answer 200 to an anonymous initialize, so OAuth scope and authorization-server discovery silently failed and connections to them could not be established. Run the discovery regardless of the probe's HTTP status: prefer the resource_metadata URL from WWW-Authenticate when present, and otherwise fall back to the RFC 9728 §4.2 well-known URIs. The trade-off is a couple of extra well-known GETs during MCP connection setup for servers that expose no PRM document; behavior for 401-responding servers is unchanged. --- backend/open_webui/utils/oauth.py | 92 ++++++++++++++++--------------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index cceb3408a77..f5d861af0cf 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -372,53 +372,57 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM headers={'Content-Type': 'application/json'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: - if response.status == 401: - resource_metadata_urls = [] - match = re.search( - r'resource_metadata=(?:"([^"]+)"|([^\s,]+))', - response.headers.get('WWW-Authenticate', ''), - ) - if match: - resource_metadata_urls = [match.group(1) or match.group(2)] - log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}') - else: - # Fall back to well-known resource metadata URIs (RFC 9728 §4.2) - parsed, base_url = get_parsed_and_base_url(server_url) - if parsed.path and parsed.path != '/': - path = parsed.path.rstrip('/') - resource_metadata_urls.append( - urllib.parse.urljoin(base_url, f'/.well-known/oauth-protected-resource{path}') - ) + # Discover Protected Resource Metadata regardless of HTTP status. + # A 401 carries a WWW-Authenticate header pointing at the PRM, but + # some MCP servers (e.g. Google's gmail/drive/calendar remote MCPs) + # answer 200 to an anonymous `initialize`, so we must still fall + # back to the RFC 9728 well-known URIs when there is no 401/header. + resource_metadata_urls = [] + match = re.search( + r'resource_metadata=(?:"([^"]+)"|([^\s,]+))', + response.headers.get('WWW-Authenticate', ''), + ) + if match: + resource_metadata_urls = [match.group(1) or match.group(2)] + log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}') + else: + # Fall back to well-known resource metadata URIs (RFC 9728 §4.2) + parsed, base_url = get_parsed_and_base_url(server_url) + if parsed.path and parsed.path != '/': + path = parsed.path.rstrip('/') resource_metadata_urls.append( - urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource') + urllib.parse.urljoin(base_url, f'/.well-known/oauth-protected-resource{path}') ) - log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}') + resource_metadata_urls.append( + urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource') + ) + log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}') - # Fetch Protected Resource metadata from candidate URLs - for resource_metadata_url in resource_metadata_urls: - try: - async with session.get( - resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL - ) as resource_response: - if resource_response.status == 200: - resource_metadata = await resource_response.json() - - resource = resource_metadata.get('resource') or None - if resource: - log.debug(f'Discovered resource indicator: {resource}') - - servers = resource_metadata.get('authorization_servers', []) - scopes = resource_metadata.get('scopes_supported', []) - if scopes: - log.debug(f'Discovered resource scopes: {scopes}') - - if servers: - authorization_servers = servers - log.debug(f'Discovered authorization servers: {servers}') - break - except Exception as e: - log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}') - continue + # Fetch Protected Resource metadata from candidate URLs + for resource_metadata_url in resource_metadata_urls: + try: + async with session.get( + resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resource_response: + if resource_response.status == 200: + resource_metadata = await resource_response.json() + + resource = resource_metadata.get('resource') or None + if resource: + log.debug(f'Discovered resource indicator: {resource}') + + servers = resource_metadata.get('authorization_servers', []) + scopes = resource_metadata.get('scopes_supported', []) + if scopes: + log.debug(f'Discovered resource scopes: {scopes}') + + if servers: + authorization_servers = servers + log.debug(f'Discovered authorization servers: {servers}') + break + except Exception as e: + log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}') + continue except Exception as e: log.debug(f'MCP Protected Resource discovery failed: {e}') From b617d56c60dadfce9443944bb1d76cd59a1e8449 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:37:38 -0500 Subject: [PATCH 241/346] refac --- src/lib/components/common/Dropdown.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index 7c813bf394c..ecda4439937 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -21,7 +21,7 @@ export let contentClass = ''; /** Max height for the dropdown content */ - export let maxHeight = '18rem'; + export let maxHeight = 'min(32rem, calc(100dvh - 2rem))'; /** Side offset in px */ export let sideOffset = 4; From f53ec857c08e0be7de945cd1d3ced7f9f13121be Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:41:16 -0400 Subject: [PATCH 242/346] feat: support folder, note, and model drag-and-drop from sidebar as context references (#25771) * feat(ui): add drag-to-reorder for pinned notes in sidebar * feat(chat): support folder, note, and model drag-and-drop from sidebar as context references --------- Co-authored-by: Tim Baek --- src/lib/components/chat/MessageInput.svelte | 47 ++++++++++++++++++- .../layout/Sidebar/PinnedModelList.svelte | 9 ++++ .../layout/Sidebar/PinnedNoteList.svelte | 10 ++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 0364870da92..607075aa2a8 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -57,6 +57,8 @@ import { generateAutoCompletion } from '$lib/apis'; import { deleteFileById } from '$lib/apis/files'; import { getChatById } from '$lib/apis/chats'; + import { getFolderById } from '$lib/apis/folders'; + import { getNoteById } from '$lib/apis/notes'; import { getSessionUser } from '$lib/apis/auths'; import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; @@ -874,7 +876,7 @@ e.preventDefault(); console.log(e); - // Check if the dropped data is a sidebar chat item + // Check if the dropped data is a sidebar chat, folder, note, or model item const textData = e.dataTransfer?.getData('text/plain'); if (textData) { try { @@ -897,6 +899,49 @@ dragged = false; e.stopPropagation(); return; + } else if (data.type === 'folder' && data.id) { + // Fetch the folder to get its name, then add as a reference folder + const folder = await getFolderById(localStorage.token, data.id); + if (folder) { + const folderItem = { + type: 'folder', + id: folder.id, + name: folder.name, + status: 'processed' + }; + if (!files.find((f) => f.id === folderItem.id)) { + files = [...files, folderItem]; + } + } + dragged = false; + e.stopPropagation(); + return; + } else if (data.type === 'note' && data.id) { + // Fetch the note to get its title, then add as a reference note + const note = await getNoteById(localStorage.token, data.id); + if (note) { + const noteItem = { + type: 'note', + id: note.id, + name: note.title, + status: 'processed' + }; + if (!files.find((f) => f.id === noteItem.id)) { + files = [...files, noteItem]; + } + } + dragged = false; + e.stopPropagation(); + return; + } else if (data.type === 'model' && data.id) { + // Find the model from the store and set as @-selected model + const model = $models.find((m) => m.id === data.id); + if (model) { + atSelectedModel = model; + } + dragged = false; + e.stopPropagation(); + return; } } catch (_) { // Not valid JSON — fall through to file handling diff --git a/src/lib/components/layout/Sidebar/PinnedModelList.svelte b/src/lib/components/layout/Sidebar/PinnedModelList.svelte index 235dda90349..c5877b4abe6 100644 --- a/src/lib/components/layout/Sidebar/PinnedModelList.svelte +++ b/src/lib/components/layout/Sidebar/PinnedModelList.svelte @@ -18,6 +18,15 @@ if (pinnedModelsList && !$mobile) { new Sortable(pinnedModelsList, { animation: 150, + setData: function (dataTransfer, dragEl) { + dataTransfer.setData( + 'text/plain', + JSON.stringify({ + type: 'model', + id: dragEl.dataset.id + }) + ); + }, onUpdate: async (event) => { const modelId = event.item.dataset.id; const newIndex = event.newIndex; diff --git a/src/lib/components/layout/Sidebar/PinnedNoteList.svelte b/src/lib/components/layout/Sidebar/PinnedNoteList.svelte index f9eb77a3c32..9eeaa89c8d1 100644 --- a/src/lib/components/layout/Sidebar/PinnedNoteList.svelte +++ b/src/lib/components/layout/Sidebar/PinnedNoteList.svelte @@ -54,6 +54,16 @@
{ + e.dataTransfer.setData( + 'text/plain', + JSON.stringify({ + type: 'note', + id: note.id + }) + ); + }} > Date: Mon, 29 Jun 2026 04:42:02 -0500 Subject: [PATCH 243/346] refac --- backend/open_webui/routers/knowledge.py | 34 +++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 4b91d5879bc..8a60f794956 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -335,12 +335,19 @@ async def reindex_knowledge_files( ) knowledge_bases = await Knowledges.get_knowledge_bases(db=db) - - log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases') - - for knowledge_base in knowledge_bases: + knowledge_base_files = [ + (knowledge_base, await Knowledges.get_files_by_id(knowledge_base.id, db=db)) + for knowledge_base in knowledge_bases + ] + total_files = sum(len(files) for _, files in knowledge_base_files) + processed_files = 0 + failed_files = [] + start_time = time.monotonic() + + log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases ({total_files} files)') + + for kb_idx, (knowledge_base, files) in enumerate(knowledge_base_files, start=1): try: - files = await Knowledges.get_files_by_id(knowledge_base.id, db=db) try: if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) @@ -348,8 +355,19 @@ async def reindex_knowledge_files( log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}') continue # Skip, don't raise - failed_files = [] for file in files: + processed_files += 1 + eta = '' + if processed_files > 1: + elapsed = time.monotonic() - start_time + remaining_files = total_files - processed_files + 1 + eta = f', ETA: {round(elapsed / (processed_files - 1) * remaining_files)}s' + + log.info( + f'Reindexing knowledge base {kb_idx}/{len(knowledge_bases)} ' + f'file {processed_files}/{total_files}{eta}: {file.filename}' + ) + try: await process_file( request, @@ -368,11 +386,11 @@ async def reindex_knowledge_files( continue if failed_files: - log.warning(f'Failed to process {len(failed_files)} files in knowledge base {knowledge_base.id}') + log.warning(f'Failed to process {len(failed_files)} files') for failed in failed_files: log.warning(f'File ID: {failed["file_id"]}, Error: {failed["error"]}') - log.info(f'Reindexing completed.') + log.info(f'Reindexing completed in {round(time.monotonic() - start_time)}s.') await publish_event( request, EVENTS.KNOWLEDGE_REINDEXED, From 67c9de8efd5241a2aaa345fbdeddb283099d70de Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:42:59 -0500 Subject: [PATCH 244/346] refac --- backend/open_webui/config.py | 154 +++++++++++++++------------- backend/open_webui/main.py | 8 +- backend/open_webui/models/config.py | 36 ++++++- 3 files changed, 123 insertions(+), 75 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index a2775104545..7222a478e96 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -38,6 +38,7 @@ async def seed_registered_defaults(): + await Config.rename_prefix('rag.web', 'web') await Config.repair_flattened_dict_configs() await Config.seed_defaults(DEFAULT_CONFIG) @@ -1063,11 +1064,18 @@ def reachable(host: str, port: int) -> bool: #################################### -# Web Search (RAG) +# Web Search #################################### ENABLE_WEB_SEARCH = os.getenv('ENABLE_WEB_SEARCH', 'False').lower() == 'true' +ENABLE_WEB_SEARCH_CONFIRMATION = os.getenv('ENABLE_WEB_SEARCH_CONFIRMATION', 'False').lower() == 'true' + +WEB_SEARCH_CONFIRMATION_CONTENT = os.getenv( + 'WEB_SEARCH_CONFIRMATION_CONTENT', + 'Web Search may send your query and retrieved page content to external services.', +) + WEB_SEARCH_ENGINE = os.getenv('WEB_SEARCH_ENGINE', '') BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = os.getenv('BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true' @@ -2764,77 +2772,79 @@ def feishu_oauth_register(oauth: OAuth): 'rag.ollama.api_key': RAG_OLLAMA_API_KEY, 'rag.youtube_loader_language': YOUTUBE_LOADER_LANGUAGE, 'rag.youtube_loader_proxy_url': YOUTUBE_LOADER_PROXY_URL, - 'rag.web.search.enable': ENABLE_WEB_SEARCH, - 'rag.web.search.engine': WEB_SEARCH_ENGINE, - 'rag.web.search.bypass_embedding_and_retrieval': BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - 'rag.web.search.bypass_web_loader': BYPASS_WEB_SEARCH_WEB_LOADER, - 'rag.web.search.result_count': WEB_SEARCH_RESULT_COUNT, - 'rag.web.search.domain.filter_list': WEB_SEARCH_DOMAIN_FILTER_LIST, - 'rag.web.search.concurrent_requests': WEB_SEARCH_CONCURRENT_REQUESTS, - 'rag.web.fetch.max_content_length': WEB_FETCH_MAX_CONTENT_LENGTH, - 'rag.web.loader.engine': WEB_LOADER_ENGINE, - 'rag.web.loader.concurrent_requests': WEB_LOADER_CONCURRENT_REQUESTS, - 'rag.web.loader.timeout': WEB_LOADER_TIMEOUT, - 'rag.web.loader.ssl_verification': ENABLE_WEB_LOADER_SSL_VERIFICATION, - 'rag.web.search.trust_env': WEB_SEARCH_TRUST_ENV, - 'rag.web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY, - 'rag.web.search.searxng_query_url': SEARXNG_QUERY_URL, - 'rag.web.search.searxng_language': SEARXNG_LANGUAGE, - 'rag.web.search.yacy_query_url': YACY_QUERY_URL, - 'rag.web.search.yacy_username': YACY_USERNAME, - 'rag.web.search.yacy_password': YACY_PASSWORD, - 'rag.web.search.google_pse_api_key': GOOGLE_PSE_API_KEY, - 'rag.web.search.google_pse_engine_id': GOOGLE_PSE_ENGINE_ID, - 'rag.web.search.brave_search_api_key': BRAVE_SEARCH_API_KEY, - 'rag.web.search.brave_search_context_tokens': BRAVE_SEARCH_CONTEXT_TOKENS, - 'rag.web.search.kagi_search_api_key': KAGI_SEARCH_API_KEY, - 'rag.web.search.mojeek_search_api_key': MOJEEK_SEARCH_API_KEY, - 'rag.web.search.bocha_search_api_key': BOCHA_SEARCH_API_KEY, - 'rag.web.search.serpstack_api_key': SERPSTACK_API_KEY, - 'rag.web.search.serpstack_https': SERPSTACK_HTTPS, - 'rag.web.search.serper_api_key': SERPER_API_KEY, - 'rag.web.search.serply_api_key': SERPLY_API_KEY, - 'rag.web.search.serphouse_api_key': SERPHOUSE_API_KEY, - 'rag.web.search.serphouse_domain': SERPHOUSE_DOMAIN, - 'rag.web.search.ddgs_backend': DDGS_BACKEND, - 'rag.web.search.jina_api_key': JINA_API_KEY, - 'rag.web.search.jina_api_base_url': JINA_API_BASE_URL, - 'rag.web.search.searchapi_api_key': SEARCHAPI_API_KEY, - 'rag.web.search.searchapi_engine': SEARCHAPI_ENGINE, - 'rag.web.search.serpapi_api_key': SERPAPI_API_KEY, - 'rag.web.search.serpapi_engine': SERPAPI_ENGINE, - 'rag.web.search.bing_search_v7_endpoint': BING_SEARCH_V7_ENDPOINT, - 'rag.web.search.bing_search_v7_subscription_key': BING_SEARCH_V7_SUBSCRIPTION_KEY, - 'rag.web.search.azure_ai_search_api_key': AZURE_AI_SEARCH_API_KEY, - 'rag.web.search.azure_ai_search_endpoint': AZURE_AI_SEARCH_ENDPOINT, - 'rag.web.search.azure_ai_search_index_name': AZURE_AI_SEARCH_INDEX_NAME, - 'rag.web.search.exa_api_key': EXA_API_KEY, - 'rag.web.search.perplexity_api_key': PERPLEXITY_API_KEY, - 'rag.web.search.perplexity_model': PERPLEXITY_MODEL, - 'rag.web.search.perplexity_search_context_usage': PERPLEXITY_SEARCH_CONTEXT_USAGE, - 'rag.web.search.perplexity_search_api_url': PERPLEXITY_SEARCH_API_URL, - 'rag.web.search.microsoft_web_iq_api_base_url': MICROSOFT_WEB_IQ_API_BASE_URL, - 'rag.web.search.microsoft_web_iq_api_key': MICROSOFT_WEB_IQ_API_KEY, - 'rag.web.search.microsoft_web_iq_language': MICROSOFT_WEB_IQ_LANGUAGE, - 'rag.web.search.sougou_api_sid': SOUGOU_API_SID, - 'rag.web.search.sougou_api_sk': SOUGOU_API_SK, - 'rag.web.search.tavily_api_key': TAVILY_API_KEY, - 'rag.web.search.tavily_extract_depth': TAVILY_EXTRACT_DEPTH, - 'rag.web.loader.playwright_ws_url': PLAYWRIGHT_WS_URL, - 'rag.web.loader.playwright_timeout': PLAYWRIGHT_TIMEOUT, - 'rag.web.loader.firecrawl_api_key': FIRECRAWL_API_KEY, - 'rag.web.loader.firecrawl_api_url': FIRECRAWL_API_BASE_URL, - 'rag.web.loader.firecrawl_timeout': FIRECRAWL_TIMEOUT, - 'rag.web.search.external_web_search_url': EXTERNAL_WEB_SEARCH_URL, - 'rag.web.search.external_web_search_api_key': EXTERNAL_WEB_SEARCH_API_KEY, - 'rag.web.loader.external_web_loader_url': EXTERNAL_WEB_LOADER_URL, - 'rag.web.loader.external_web_loader_api_key': EXTERNAL_WEB_LOADER_API_KEY, - 'rag.web.search.yandex_web_search_url': YANDEX_WEB_SEARCH_URL, - 'rag.web.search.yandex_web_search_api_key': YANDEX_WEB_SEARCH_API_KEY, - 'rag.web.search.yandex_web_search_config': YANDEX_WEB_SEARCH_CONFIG, - 'rag.web.search.youcom_api_key': YOUCOM_API_KEY, - 'rag.web.search.linkup_api_key': LINKUP_API_KEY, - 'rag.web.search.linkup_search_params': LINKUP_SEARCH_PARAMS, + 'web.search.enable': ENABLE_WEB_SEARCH, + 'web.search.confirmation.enable': ENABLE_WEB_SEARCH_CONFIRMATION, + 'web.search.confirmation.content': WEB_SEARCH_CONFIRMATION_CONTENT, + 'web.search.engine': WEB_SEARCH_ENGINE, + 'web.search.bypass_embedding_and_retrieval': BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'web.search.bypass_web_loader': BYPASS_WEB_SEARCH_WEB_LOADER, + 'web.search.result_count': WEB_SEARCH_RESULT_COUNT, + 'web.search.domain.filter_list': WEB_SEARCH_DOMAIN_FILTER_LIST, + 'web.search.concurrent_requests': WEB_SEARCH_CONCURRENT_REQUESTS, + 'web.fetch.max_content_length': WEB_FETCH_MAX_CONTENT_LENGTH, + 'web.loader.engine': WEB_LOADER_ENGINE, + 'web.loader.concurrent_requests': WEB_LOADER_CONCURRENT_REQUESTS, + 'web.loader.timeout': WEB_LOADER_TIMEOUT, + 'web.loader.ssl_verification': ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'web.search.trust_env': WEB_SEARCH_TRUST_ENV, + 'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'web.search.searxng_query_url': SEARXNG_QUERY_URL, + 'web.search.searxng_language': SEARXNG_LANGUAGE, + 'web.search.yacy_query_url': YACY_QUERY_URL, + 'web.search.yacy_username': YACY_USERNAME, + 'web.search.yacy_password': YACY_PASSWORD, + 'web.search.google_pse_api_key': GOOGLE_PSE_API_KEY, + 'web.search.google_pse_engine_id': GOOGLE_PSE_ENGINE_ID, + 'web.search.brave_search_api_key': BRAVE_SEARCH_API_KEY, + 'web.search.brave_search_context_tokens': BRAVE_SEARCH_CONTEXT_TOKENS, + 'web.search.kagi_search_api_key': KAGI_SEARCH_API_KEY, + 'web.search.mojeek_search_api_key': MOJEEK_SEARCH_API_KEY, + 'web.search.bocha_search_api_key': BOCHA_SEARCH_API_KEY, + 'web.search.serpstack_api_key': SERPSTACK_API_KEY, + 'web.search.serpstack_https': SERPSTACK_HTTPS, + 'web.search.serper_api_key': SERPER_API_KEY, + 'web.search.serply_api_key': SERPLY_API_KEY, + 'web.search.serphouse_api_key': SERPHOUSE_API_KEY, + 'web.search.serphouse_domain': SERPHOUSE_DOMAIN, + 'web.search.ddgs_backend': DDGS_BACKEND, + 'web.search.jina_api_key': JINA_API_KEY, + 'web.search.jina_api_base_url': JINA_API_BASE_URL, + 'web.search.searchapi_api_key': SEARCHAPI_API_KEY, + 'web.search.searchapi_engine': SEARCHAPI_ENGINE, + 'web.search.serpapi_api_key': SERPAPI_API_KEY, + 'web.search.serpapi_engine': SERPAPI_ENGINE, + 'web.search.bing_search_v7_endpoint': BING_SEARCH_V7_ENDPOINT, + 'web.search.bing_search_v7_subscription_key': BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'web.search.azure_ai_search_api_key': AZURE_AI_SEARCH_API_KEY, + 'web.search.azure_ai_search_endpoint': AZURE_AI_SEARCH_ENDPOINT, + 'web.search.azure_ai_search_index_name': AZURE_AI_SEARCH_INDEX_NAME, + 'web.search.exa_api_key': EXA_API_KEY, + 'web.search.perplexity_api_key': PERPLEXITY_API_KEY, + 'web.search.perplexity_model': PERPLEXITY_MODEL, + 'web.search.perplexity_search_context_usage': PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'web.search.perplexity_search_api_url': PERPLEXITY_SEARCH_API_URL, + 'web.search.microsoft_web_iq_api_base_url': MICROSOFT_WEB_IQ_API_BASE_URL, + 'web.search.microsoft_web_iq_api_key': MICROSOFT_WEB_IQ_API_KEY, + 'web.search.microsoft_web_iq_language': MICROSOFT_WEB_IQ_LANGUAGE, + 'web.search.sougou_api_sid': SOUGOU_API_SID, + 'web.search.sougou_api_sk': SOUGOU_API_SK, + 'web.search.tavily_api_key': TAVILY_API_KEY, + 'web.search.tavily_extract_depth': TAVILY_EXTRACT_DEPTH, + 'web.loader.playwright_ws_url': PLAYWRIGHT_WS_URL, + 'web.loader.playwright_timeout': PLAYWRIGHT_TIMEOUT, + 'web.loader.firecrawl_api_key': FIRECRAWL_API_KEY, + 'web.loader.firecrawl_api_url': FIRECRAWL_API_BASE_URL, + 'web.loader.firecrawl_timeout': FIRECRAWL_TIMEOUT, + 'web.search.external_web_search_url': EXTERNAL_WEB_SEARCH_URL, + 'web.search.external_web_search_api_key': EXTERNAL_WEB_SEARCH_API_KEY, + 'web.loader.external_web_loader_url': EXTERNAL_WEB_LOADER_URL, + 'web.loader.external_web_loader_api_key': EXTERNAL_WEB_LOADER_API_KEY, + 'web.search.yandex_web_search_url': YANDEX_WEB_SEARCH_URL, + 'web.search.yandex_web_search_api_key': YANDEX_WEB_SEARCH_API_KEY, + 'web.search.yandex_web_search_config': YANDEX_WEB_SEARCH_CONFIG, + 'web.search.youcom_api_key': YOUCOM_API_KEY, + 'web.search.linkup_api_key': LINKUP_API_KEY, + 'web.search.linkup_search_params': LINKUP_SEARCH_PARAMS, 'image_generation.enable': ENABLE_IMAGE_GENERATION, 'image_generation.engine': IMAGE_GENERATION_ENGINE, 'image_generation.model': IMAGE_GENERATION_MODEL, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 166ee55b3d6..aa93d27328f 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1867,7 +1867,9 @@ async def get_app_config(request: Request): 'calendar.enable', 'automations.enable', 'notes.enable', - 'rag.web.search.enable', + 'web.search.enable', + 'web.search.confirmation.enable', + 'web.search.confirmation.content', 'code_execution.enable', 'code_interpreter.enable', 'image_generation.enable', @@ -1933,7 +1935,9 @@ async def get_app_config(request: Request): 'enable_calendar': config.get('calendar.enable'), 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), - 'enable_web_search': config.get('rag.web.search.enable'), + 'enable_web_search': config.get('web.search.enable'), + 'enable_web_search_confirmation': config.get('web.search.confirmation.enable'), + 'web_search_confirmation_content': config.get('web.search.confirmation.content'), 'enable_code_execution': config.get('code_execution.enable'), 'enable_code_interpreter': config.get('code_interpreter.enable'), 'enable_image_generation': config.get('image_generation.enable'), diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 9e971e2c749..86d325c8940 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -25,7 +25,7 @@ 'ollama.api_configs': ('OLLAMA_API_CONFIGS',), 'rag.mineru_params': ('MINERU_PARAMS',), 'rag.docling_params': ('DOCLING_PARAMS',), - 'rag.web.search.linkup_search_params': ('LINKUP_SEARCH_PARAMS',), + 'web.search.linkup_search_params': ('LINKUP_SEARCH_PARAMS',), 'image_generation.automatic1111.api_params': ('AUTOMATIC1111_PARAMS',), 'image_generation.openai.params': ('IMAGES_OPENAI_API_PARAMS',), 'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',), @@ -240,6 +240,40 @@ async def seed_defaults(defaults: dict) -> None: await db.commit() log.info('Seeded %d new config defaults', new_count) + @staticmethod + async def rename_prefix(old_prefix: str, new_prefix: str) -> None: + """Move persisted config keys from one dotted prefix to another.""" + if not Config.PERSISTENT_ENABLED: + return + + async with get_async_db() as db: + result = await db.execute(select(Config).where(Config.key.like(f'{old_prefix}.%'))) + rows = result.scalars().all() + if not rows: + return + + now = int(time.time()) + moved_count = 0 + deleted_count = 0 + for row in rows: + new_key = f'{new_prefix}.{row.key.removeprefix(f"{old_prefix}.")}' + existing = await db.get(Config, new_key) + if existing is None: + db.add(Config(key=new_key, value=row.value, updated_at=now)) + moved_count += 1 + else: + deleted_count += 1 + await db.delete(row) + + await db.commit() + log.info( + 'Renamed %d config keys from %s.* to %s.*; deleted %d old duplicates', + moved_count, + old_prefix, + new_prefix, + deleted_count, + ) + @staticmethod async def repair_flattened_dict_configs() -> None: """Reassemble dict config values flattened by the per-key migration.""" From e5b5e5917b051d590fb1eec824eec14370de8a57 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:43:08 -0500 Subject: [PATCH 245/346] refac --- backend/open_webui/retrieval/utils.py | 6 +- backend/open_webui/routers/retrieval.py | 152 +++++++++++++----------- backend/open_webui/tools/builtin.py | 6 +- 3 files changed, 87 insertions(+), 77 deletions(-) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index f72625f7216..16a3c2c2494 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -68,9 +68,9 @@ def is_youtube_url(url: str) -> bool: LOADER_CONFIG_KEYS = { 'youtube_language': 'rag.youtube_loader_language', 'youtube_proxy_url': 'rag.youtube_loader_proxy_url', - 'web_loader_ssl_verification': 'rag.web.loader.ssl_verification', - 'web_loader_concurrent_requests': 'rag.web.loader.concurrent_requests', - 'web_search_trust_env': 'rag.web.search.trust_env', + 'web_loader_ssl_verification': 'web.loader.ssl_verification', + 'web_loader_concurrent_requests': 'web.loader.concurrent_requests', + 'web_search_trust_env': 'web.search.trust_env', 'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine', 'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key', 'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url', diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index ec19922fa96..080a344a361 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -248,17 +248,17 @@ def get_rf( RETRIEVAL_CONFIG_KEYS = { 'ALLOWED_FILE_EXTENSIONS': 'rag.file.allowed_extensions', - 'AZURE_AI_SEARCH_API_KEY': 'rag.web.search.azure_ai_search_api_key', - 'AZURE_AI_SEARCH_ENDPOINT': 'rag.web.search.azure_ai_search_endpoint', - 'AZURE_AI_SEARCH_INDEX_NAME': 'rag.web.search.azure_ai_search_index_name', - 'BING_SEARCH_V7_ENDPOINT': 'rag.web.search.bing_search_v7_endpoint', - 'BING_SEARCH_V7_SUBSCRIPTION_KEY': 'rag.web.search.bing_search_v7_subscription_key', - 'BOCHA_SEARCH_API_KEY': 'rag.web.search.bocha_search_api_key', - 'BRAVE_SEARCH_API_KEY': 'rag.web.search.brave_search_api_key', - 'BRAVE_SEARCH_CONTEXT_TOKENS': 'rag.web.search.brave_search_context_tokens', + 'AZURE_AI_SEARCH_API_KEY': 'web.search.azure_ai_search_api_key', + 'AZURE_AI_SEARCH_ENDPOINT': 'web.search.azure_ai_search_endpoint', + 'AZURE_AI_SEARCH_INDEX_NAME': 'web.search.azure_ai_search_index_name', + 'BING_SEARCH_V7_ENDPOINT': 'web.search.bing_search_v7_endpoint', + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': 'web.search.bing_search_v7_subscription_key', + 'BOCHA_SEARCH_API_KEY': 'web.search.bocha_search_api_key', + 'BRAVE_SEARCH_API_KEY': 'web.search.brave_search_api_key', + 'BRAVE_SEARCH_CONTEXT_TOKENS': 'web.search.brave_search_context_tokens', 'BYPASS_EMBEDDING_AND_RETRIEVAL': 'rag.bypass_embedding_and_retrieval', - 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': 'rag.web.search.bypass_embedding_and_retrieval', - 'BYPASS_WEB_SEARCH_WEB_LOADER': 'rag.web.search.bypass_web_loader', + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': 'web.search.bypass_embedding_and_retrieval', + 'BYPASS_WEB_SEARCH_WEB_LOADER': 'web.search.bypass_web_loader', 'CHUNK_MIN_SIZE_TARGET': 'rag.chunk_min_size_target', 'CHUNK_OVERLAP': 'rag.chunk_overlap', 'CHUNK_SIZE': 'rag.chunk_size', @@ -274,7 +274,7 @@ def get_rf( 'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache', 'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr', 'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm', - 'DDGS_BACKEND': 'rag.web.search.ddgs_backend', + 'DDGS_BACKEND': 'web.search.ddgs_backend', 'DOCLING_API_KEY': 'rag.docling_api_key', 'DOCLING_PARAMS': 'rag.docling_params', 'DOCLING_SERVER_URL': 'rag.docling_server_url', @@ -287,54 +287,56 @@ def get_rf( 'ENABLE_ONEDRIVE_INTEGRATION': 'onedrive.enable', 'ENABLE_RAG_HYBRID_SEARCH': 'rag.enable_hybrid_search', 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS': 'rag.enable_hybrid_search_enriched_texts', - 'ENABLE_WEB_LOADER_SSL_VERIFICATION': 'rag.web.loader.ssl_verification', - 'ENABLE_WEB_SEARCH': 'rag.web.search.enable', - 'EXA_API_KEY': 'rag.web.search.exa_api_key', + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': 'web.loader.ssl_verification', + 'ENABLE_WEB_SEARCH': 'web.search.enable', + 'ENABLE_WEB_SEARCH_CONFIRMATION': 'web.search.confirmation.enable', + 'WEB_SEARCH_CONFIRMATION_CONTENT': 'web.search.confirmation.content', + 'EXA_API_KEY': 'web.search.exa_api_key', 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', 'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers', 'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url', - 'EXTERNAL_WEB_LOADER_API_KEY': 'rag.web.loader.external_web_loader_api_key', - 'EXTERNAL_WEB_LOADER_URL': 'rag.web.loader.external_web_loader_url', - 'EXTERNAL_WEB_SEARCH_API_KEY': 'rag.web.search.external_web_search_api_key', - 'EXTERNAL_WEB_SEARCH_URL': 'rag.web.search.external_web_search_url', + 'EXTERNAL_WEB_LOADER_API_KEY': 'web.loader.external_web_loader_api_key', + 'EXTERNAL_WEB_LOADER_URL': 'web.loader.external_web_loader_url', + 'EXTERNAL_WEB_SEARCH_API_KEY': 'web.search.external_web_search_api_key', + 'EXTERNAL_WEB_SEARCH_URL': 'web.search.external_web_search_url', 'FILE_IMAGE_COMPRESSION_HEIGHT': 'file.image_compression_height', 'FILE_IMAGE_COMPRESSION_WIDTH': 'file.image_compression_width', 'FILE_MAX_COUNT': 'rag.file.max_count', 'FILE_MAX_SIZE': 'rag.file.max_size', - 'FIRECRAWL_API_BASE_URL': 'rag.web.loader.firecrawl_api_url', - 'FIRECRAWL_API_KEY': 'rag.web.loader.firecrawl_api_key', - 'FIRECRAWL_TIMEOUT': 'rag.web.loader.firecrawl_timeout', - 'GOOGLE_PSE_API_KEY': 'rag.web.search.google_pse_api_key', - 'GOOGLE_PSE_ENGINE_ID': 'rag.web.search.google_pse_engine_id', + 'FIRECRAWL_API_BASE_URL': 'web.loader.firecrawl_api_url', + 'FIRECRAWL_API_KEY': 'web.loader.firecrawl_api_key', + 'FIRECRAWL_TIMEOUT': 'web.loader.firecrawl_timeout', + 'GOOGLE_PSE_API_KEY': 'web.search.google_pse_api_key', + 'GOOGLE_PSE_ENGINE_ID': 'web.search.google_pse_engine_id', 'HYBRID_BM25_WEIGHT': 'rag.hybrid_bm25_weight', - 'JINA_API_BASE_URL': 'rag.web.search.jina_api_base_url', - 'JINA_API_KEY': 'rag.web.search.jina_api_key', - 'KAGI_SEARCH_API_KEY': 'rag.web.search.kagi_search_api_key', - 'LINKUP_API_KEY': 'rag.web.search.linkup_api_key', - 'LINKUP_SEARCH_PARAMS': 'rag.web.search.linkup_search_params', + 'JINA_API_BASE_URL': 'web.search.jina_api_base_url', + 'JINA_API_KEY': 'web.search.jina_api_key', + 'KAGI_SEARCH_API_KEY': 'web.search.kagi_search_api_key', + 'LINKUP_API_KEY': 'web.search.linkup_api_key', + 'LINKUP_SEARCH_PARAMS': 'web.search.linkup_search_params', 'MINERU_API_KEY': 'rag.mineru_api_key', 'MINERU_API_MODE': 'rag.mineru_api_mode', 'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout', 'MINERU_API_URL': 'rag.mineru_api_url', 'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions', 'MINERU_PARAMS': 'rag.mineru_params', - 'MICROSOFT_WEB_IQ_API_BASE_URL': 'rag.web.search.microsoft_web_iq_api_base_url', - 'MICROSOFT_WEB_IQ_API_KEY': 'rag.web.search.microsoft_web_iq_api_key', - 'MICROSOFT_WEB_IQ_LANGUAGE': 'rag.web.search.microsoft_web_iq_language', + 'MICROSOFT_WEB_IQ_API_BASE_URL': 'web.search.microsoft_web_iq_api_base_url', + 'MICROSOFT_WEB_IQ_API_KEY': 'web.search.microsoft_web_iq_api_key', + 'MICROSOFT_WEB_IQ_LANGUAGE': 'web.search.microsoft_web_iq_language', 'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url', 'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key', - 'MOJEEK_SEARCH_API_KEY': 'rag.web.search.mojeek_search_api_key', - 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': 'rag.web.search.ollama_cloud_api_key', + 'MOJEEK_SEARCH_API_KEY': 'web.search.mojeek_search_api_key', + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': 'web.search.ollama_cloud_api_key', 'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url', 'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token', 'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images', 'PDF_LOADER_MODE': 'rag.pdf_loader_mode', - 'PERPLEXITY_API_KEY': 'rag.web.search.perplexity_api_key', - 'PERPLEXITY_MODEL': 'rag.web.search.perplexity_model', - 'PERPLEXITY_SEARCH_API_URL': 'rag.web.search.perplexity_search_api_url', - 'PERPLEXITY_SEARCH_CONTEXT_USAGE': 'rag.web.search.perplexity_search_context_usage', - 'PLAYWRIGHT_TIMEOUT': 'rag.web.loader.playwright_timeout', - 'PLAYWRIGHT_WS_URL': 'rag.web.loader.playwright_ws_url', + 'PERPLEXITY_API_KEY': 'web.search.perplexity_api_key', + 'PERPLEXITY_MODEL': 'web.search.perplexity_model', + 'PERPLEXITY_SEARCH_API_URL': 'web.search.perplexity_search_api_url', + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': 'web.search.perplexity_search_context_usage', + 'PLAYWRIGHT_TIMEOUT': 'web.loader.playwright_timeout', + 'PLAYWRIGHT_WS_URL': 'web.loader.playwright_ws_url', 'RAG_AZURE_OPENAI_API_KEY': 'rag.azure_openai.api_key', 'RAG_AZURE_OPENAI_API_VERSION': 'rag.azure_openai.api_version', 'RAG_AZURE_OPENAI_BASE_URL': 'rag.azure_openai.base_url', @@ -355,22 +357,22 @@ def get_rf( 'RAG_RERANKING_MODEL': 'rag.reranking_model', 'RAG_TEMPLATE': 'rag.template', 'RELEVANCE_THRESHOLD': 'rag.relevance_threshold', - 'SEARCHAPI_API_KEY': 'rag.web.search.searchapi_api_key', - 'SEARCHAPI_ENGINE': 'rag.web.search.searchapi_engine', - 'SEARXNG_LANGUAGE': 'rag.web.search.searxng_language', - 'SEARXNG_QUERY_URL': 'rag.web.search.searxng_query_url', - 'SERPAPI_API_KEY': 'rag.web.search.serpapi_api_key', - 'SERPAPI_ENGINE': 'rag.web.search.serpapi_engine', - 'SERPER_API_KEY': 'rag.web.search.serper_api_key', - 'SERPHOUSE_API_KEY': 'rag.web.search.serphouse_api_key', - 'SERPHOUSE_DOMAIN': 'rag.web.search.serphouse_domain', - 'SERPLY_API_KEY': 'rag.web.search.serply_api_key', - 'SERPSTACK_API_KEY': 'rag.web.search.serpstack_api_key', - 'SERPSTACK_HTTPS': 'rag.web.search.serpstack_https', - 'SOUGOU_API_SID': 'rag.web.search.sougou_api_sid', - 'SOUGOU_API_SK': 'rag.web.search.sougou_api_sk', - 'TAVILY_API_KEY': 'rag.web.search.tavily_api_key', - 'TAVILY_EXTRACT_DEPTH': 'rag.web.search.tavily_extract_depth', + 'SEARCHAPI_API_KEY': 'web.search.searchapi_api_key', + 'SEARCHAPI_ENGINE': 'web.search.searchapi_engine', + 'SEARXNG_LANGUAGE': 'web.search.searxng_language', + 'SEARXNG_QUERY_URL': 'web.search.searxng_query_url', + 'SERPAPI_API_KEY': 'web.search.serpapi_api_key', + 'SERPAPI_ENGINE': 'web.search.serpapi_engine', + 'SERPER_API_KEY': 'web.search.serper_api_key', + 'SERPHOUSE_API_KEY': 'web.search.serphouse_api_key', + 'SERPHOUSE_DOMAIN': 'web.search.serphouse_domain', + 'SERPLY_API_KEY': 'web.search.serply_api_key', + 'SERPSTACK_API_KEY': 'web.search.serpstack_api_key', + 'SERPSTACK_HTTPS': 'web.search.serpstack_https', + 'SOUGOU_API_SID': 'web.search.sougou_api_sid', + 'SOUGOU_API_SK': 'web.search.sougou_api_sk', + 'TAVILY_API_KEY': 'web.search.tavily_api_key', + 'TAVILY_EXTRACT_DEPTH': 'web.search.tavily_extract_depth', 'TEXT_SPLITTER': 'rag.text_splitter', 'TIKA_SERVER_URL': 'rag.tika_server_url', 'TIKTOKEN_ENCODING_NAME': 'rag.tiktoken_encoding_name', @@ -378,22 +380,22 @@ def get_rf( 'TOP_K_RERANKER': 'rag.top_k_reranker', 'USER_PERMISSIONS': 'user.permissions', 'WEBUI_URL': 'webui.url', - 'WEB_FETCH_MAX_CONTENT_LENGTH': 'rag.web.fetch.max_content_length', - 'WEB_LOADER_CONCURRENT_REQUESTS': 'rag.web.loader.concurrent_requests', - 'WEB_LOADER_ENGINE': 'rag.web.loader.engine', - 'WEB_LOADER_TIMEOUT': 'rag.web.loader.timeout', - 'WEB_SEARCH_CONCURRENT_REQUESTS': 'rag.web.search.concurrent_requests', - 'WEB_SEARCH_DOMAIN_FILTER_LIST': 'rag.web.search.domain.filter_list', - 'WEB_SEARCH_ENGINE': 'rag.web.search.engine', - 'WEB_SEARCH_RESULT_COUNT': 'rag.web.search.result_count', - 'WEB_SEARCH_TRUST_ENV': 'rag.web.search.trust_env', - 'YACY_PASSWORD': 'rag.web.search.yacy_password', - 'YACY_QUERY_URL': 'rag.web.search.yacy_query_url', - 'YACY_USERNAME': 'rag.web.search.yacy_username', - 'YANDEX_WEB_SEARCH_API_KEY': 'rag.web.search.yandex_web_search_api_key', - 'YANDEX_WEB_SEARCH_CONFIG': 'rag.web.search.yandex_web_search_config', - 'YANDEX_WEB_SEARCH_URL': 'rag.web.search.yandex_web_search_url', - 'YOUCOM_API_KEY': 'rag.web.search.youcom_api_key', + 'WEB_FETCH_MAX_CONTENT_LENGTH': 'web.fetch.max_content_length', + 'WEB_LOADER_CONCURRENT_REQUESTS': 'web.loader.concurrent_requests', + 'WEB_LOADER_ENGINE': 'web.loader.engine', + 'WEB_LOADER_TIMEOUT': 'web.loader.timeout', + 'WEB_SEARCH_CONCURRENT_REQUESTS': 'web.search.concurrent_requests', + 'WEB_SEARCH_DOMAIN_FILTER_LIST': 'web.search.domain.filter_list', + 'WEB_SEARCH_ENGINE': 'web.search.engine', + 'WEB_SEARCH_RESULT_COUNT': 'web.search.result_count', + 'WEB_SEARCH_TRUST_ENV': 'web.search.trust_env', + 'YACY_PASSWORD': 'web.search.yacy_password', + 'YACY_QUERY_URL': 'web.search.yacy_query_url', + 'YACY_USERNAME': 'web.search.yacy_username', + 'YANDEX_WEB_SEARCH_API_KEY': 'web.search.yandex_web_search_api_key', + 'YANDEX_WEB_SEARCH_CONFIG': 'web.search.yandex_web_search_config', + 'YANDEX_WEB_SEARCH_URL': 'web.search.yandex_web_search_url', + 'YOUCOM_API_KEY': 'web.search.youcom_api_key', 'YOUTUBE_LOADER_LANGUAGE': 'rag.youtube_loader_language', 'YOUTUBE_LOADER_PROXY_URL': 'rag.youtube_loader_proxy_url', } @@ -685,6 +687,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): # Web search settings 'web': { 'ENABLE_WEB_SEARCH': config.ENABLE_WEB_SEARCH, + 'ENABLE_WEB_SEARCH_CONFIRMATION': config.ENABLE_WEB_SEARCH_CONFIRMATION, + 'WEB_SEARCH_CONFIRMATION_CONTENT': config.WEB_SEARCH_CONFIRMATION_CONTENT, 'WEB_SEARCH_ENGINE': config.WEB_SEARCH_ENGINE, 'WEB_SEARCH_TRUST_ENV': config.WEB_SEARCH_TRUST_ENV, 'WEB_SEARCH_RESULT_COUNT': config.WEB_SEARCH_RESULT_COUNT, @@ -761,6 +765,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): class WebConfig(BaseModel): ENABLE_WEB_SEARCH: bool | None = None + ENABLE_WEB_SEARCH_CONFIRMATION: bool | None = None + WEB_SEARCH_CONFIRMATION_CONTENT: str | None = None WEB_SEARCH_ENGINE: str | None = None WEB_SEARCH_TRUST_ENV: bool | None = None WEB_SEARCH_RESULT_COUNT: int | None = None @@ -1262,6 +1268,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend if form_data.web is not None: # Web search settings config.ENABLE_WEB_SEARCH = form_data.web.ENABLE_WEB_SEARCH + config.ENABLE_WEB_SEARCH_CONFIRMATION = form_data.web.ENABLE_WEB_SEARCH_CONFIRMATION + config.WEB_SEARCH_CONFIRMATION_CONTENT = form_data.web.WEB_SEARCH_CONFIRMATION_CONTENT config.WEB_SEARCH_ENGINE = form_data.web.WEB_SEARCH_ENGINE config.WEB_SEARCH_TRUST_ENV = form_data.web.WEB_SEARCH_TRUST_ENV config.WEB_SEARCH_RESULT_COUNT = form_data.web.WEB_SEARCH_RESULT_COUNT @@ -1411,6 +1419,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend # Web search settings 'web': { 'ENABLE_WEB_SEARCH': config.ENABLE_WEB_SEARCH, + 'ENABLE_WEB_SEARCH_CONFIRMATION': config.ENABLE_WEB_SEARCH_CONFIRMATION, + 'WEB_SEARCH_CONFIRMATION_CONTENT': config.WEB_SEARCH_CONFIRMATION_CONTENT, 'WEB_SEARCH_ENGINE': config.WEB_SEARCH_ENGINE, 'WEB_SEARCH_TRUST_ENV': config.WEB_SEARCH_TRUST_ENV, 'WEB_SEARCH_RESULT_COUNT': config.WEB_SEARCH_RESULT_COUNT, diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 06f34dd890e..72bf809661a 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -228,10 +228,10 @@ async def search_web( return json.dumps({'error': 'Request context not available'}) try: - engine = await Config.get('rag.web.search.engine') + engine = await Config.get('web.search.engine') user = UserModel(**__user__) if __user__ else None - configured = await Config.get('rag.web.search.result_count') + configured = await Config.get('web.search.result_count') max_count = 5 if configured is None else configured count = max(1, min(count, max_count)) if count is not None else max_count @@ -269,7 +269,7 @@ async def fetch_url( # Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH) # Guard: content may be None if the web loader silently failed if content is not None: - max_length = await Config.get('rag.web.fetch.max_content_length') + max_length = await Config.get('web.fetch.max_content_length') if max_length and max_length > 0 and len(content) > max_length: content = content[:max_length] + '\n\n[Content truncated...]' else: From 953432b5fe87abc0e118d649e90d8307cf7426db Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:43:40 -0500 Subject: [PATCH 246/346] refac --- backend/open_webui/utils/automations.py | 2 +- backend/open_webui/utils/tools.py | 4 ++-- src/lib/apis/retrieval/index.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index c826dd5742e..a2342f094b8 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -273,7 +273,7 @@ async def _resolve_model_features(app, model_id: str) -> dict: # code_interpreter is excluded: it requires the frontend event emitter # and does not work in headless backend execution. feature_checks = { - 'web_search': await Config.get('rag.web.search.enable'), + 'web_search': await Config.get('web.search.enable'), 'image_generation': await Config.get('image_generation.enable'), } diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 9aba0b57e6d..3f3455d2557 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -476,7 +476,7 @@ def is_builtin_tool_enabled(category: str) -> bool: # Helper to check user-level feature permission (admins always pass) user = extra_params.get('__user__', {}) config = await Config.get_many( - 'rag.web.search.enable', + 'web.search.enable', 'image_generation.enable', 'images.edit.enable', 'code_interpreter.enable', @@ -569,7 +569,7 @@ async def has_user_permission(feature_key: str) -> bool: # Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability if ( is_builtin_tool_enabled('web_search') - and config.get('rag.web.search.enable') + and config.get('web.search.enable') and get_model_capability('web_search') and features.get('web_search') and await has_user_permission('web_search') diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index ffab4064096..dccb5950b30 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -58,6 +58,7 @@ type RAGConfigForm = { chunk?: ChunkConfigForm; content_extraction?: ContentExtractConfigForm; web_loader_ssl_verification?: boolean; + web?: Record; youtube?: YoutubeConfigForm; }; From 45fea34bd0c8ce54b0822499c40e3e6964220354 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:46:56 -0500 Subject: [PATCH 247/346] refac Co-Authored-By: Pedro Machado <45520277+pedrofcm@users.noreply.github.com> --- backend/open_webui/utils/oauth.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index f5d861af0cf..5b931222de9 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -928,7 +928,16 @@ async def _preflight_authorization_url(self, client, client_info: OAuthClientInf error_message = f'{error or ""} {error_description or ""}'.lower() - if any(keyword in error_message for keyword in ('invalid_client', 'invalid client', 'client id')): + if any( + keyword in error_message + for keyword in ( + 'invalid_client', + 'invalid client', + 'client id', + 'redirect_uri', + 'redirect uri', + ) + ): log.warning( f'OAuth client preflight detected invalid registration for {client_info.client_id}: {error} {error_description}' ) From 416baef813ec1124dfb98b378aa5075b9d9caa6d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 04:57:28 -0500 Subject: [PATCH 248/346] refac --- backend/open_webui/config.py | 2 +- src/lib/stores/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 7222a478e96..f799b090579 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1073,7 +1073,7 @@ def reachable(host: str, port: int) -> bool: WEB_SEARCH_CONFIRMATION_CONTENT = os.getenv( 'WEB_SEARCH_CONFIRMATION_CONTENT', - 'Web Search may send your query and retrieved page content to external services.', + 'Your query will be sent to the configured web search provider.', ) WEB_SEARCH_ENGINE = os.getenv('WEB_SEARCH_ENGINE', '') diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index ae6eede3924..07df7a37362 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -292,6 +292,8 @@ type Config = { enable_signup: boolean; enable_login_form: boolean; enable_web_search?: boolean; + enable_web_search_confirmation?: boolean; + web_search_confirmation_content?: string; enable_google_drive_integration: boolean; enable_onedrive_integration: boolean; enable_image_generation: boolean; From 4fa3a748277b61f226fc5cfcaef845b9d6be1c62 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:58:30 -0400 Subject: [PATCH 249/346] fix(chat): prevent sortable sidebar drags from triggering file upload overlay (#25675) Add a custom MIME type (application/x-open-webui-drag) to intentional chat and folder drag sources. The onDragOver handler in MessageInput now checks for this type instead of the generic text/plain, which SortableJS also sets during reorder operations for pinned menu items (Notes, Workspace) and pinned Models. --- src/lib/components/chat/MessageInput.svelte | 9 +++++++-- src/lib/components/common/Folder.svelte | 4 ++++ src/lib/components/layout/Sidebar/ChatItem.svelte | 1 + src/lib/components/layout/Sidebar/RecursiveFolder.svelte | 5 +++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 607075aa2a8..e689ed40018 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -857,8 +857,13 @@ const onDragOver = (e: DragEvent) => { e.preventDefault(); - // Check if a file or a sidebar chat item is being dragged. - if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/plain')) { + // Check if a file or a sidebar chat/folder item is being dragged. + // Use a custom MIME type to distinguish intentional drags from SortableJS reorder drags + // (e.g. Notes, Workspace, pinned Models), which also set 'text/plain'. + if ( + e.dataTransfer?.types?.includes('Files') || + e.dataTransfer?.types?.includes('application/x-open-webui-drag') + ) { dragged = true; } else { dragged = false; diff --git a/src/lib/components/common/Folder.svelte b/src/lib/components/common/Folder.svelte index 45c2386d89b..93ec72763ed 100644 --- a/src/lib/components/common/Folder.svelte +++ b/src/lib/components/common/Folder.svelte @@ -88,6 +88,10 @@ } finally { draggedOver = false; } + + // Only process the first non-file item; all share the same + // text/plain payload, so continuing would duplicate the drop. + break; } } } diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 2dd5def8e19..0e7c2bf181f 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -266,6 +266,7 @@ id: id }) ); + event.dataTransfer.setData('application/x-open-webui-drag', ''); dragged = true; itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index 085bafa395d..0f401b5abdd 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -206,6 +206,10 @@ } catch (error) { console.log('Error parsing dataTransfer:', error); } + + // Only process the first non-file item; all share the same + // text/plain payload, so continuing would duplicate the move. + break; } } } @@ -243,6 +247,7 @@ id: folderId }) ); + event.dataTransfer.setData('application/x-open-webui-drag', ''); dragged = true; folderElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged From 5ffd4e53c3333b8226af6ac1f2d09e70a0ca6566 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:59:01 -0400 Subject: [PATCH 250/346] fix(ui): prevent long default group name from overflowing admin settings layout (#25685) --- src/lib/components/admin/Settings/Authentication.svelte | 4 ++-- src/lib/components/admin/Settings/General.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/admin/Settings/Authentication.svelte b/src/lib/components/admin/Settings/Authentication.svelte index 2a7319eddb3..8e692d1c9db 100644 --- a/src/lib/components/admin/Settings/Authentication.svelte +++ b/src/lib/components/admin/Settings/Authentication.svelte @@ -135,9 +135,9 @@
{$i18n.t('Default Group')}
-
+