diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 897618247a..cc38281c4b 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1302,6 +1302,8 @@ TAVILY_API_KEY = os.getenv('TAVILY_API_KEY', '') TAVILY_EXTRACT_DEPTH = os.getenv('TAVILY_EXTRACT_DEPTH', 'basic') +TAVILY_SEARCH_DEPTH = os.getenv('TAVILY_SEARCH_DEPTH', 'basic') + STAAN_API_KEY = os.getenv('STAAN_API_KEY', '') STAAN_MARKET = os.getenv('STAAN_MARKET', 'en-us') @@ -2998,6 +3000,7 @@ DEFAULT_CONFIG = { '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.search.tavily_search_depth': TAVILY_SEARCH_DEPTH, 'web.search.staan_api_key': STAAN_API_KEY, 'web.search.staan_market': STAAN_MARKET, 'web.search.staan_max_snippets': STAAN_MAX_SNIPPETS, diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index c6c78de35a..2e5b0ff60c 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -58,6 +58,7 @@ class ERROR_MESSAGES(str, Enum): INVALID_TOKEN = 'Your session has expired or the token is invalid. Please sign in again.' INVALID_CRED = 'The email or password provided is incorrect. Please check for typos and try logging in again.' + OAUTH_LOGIN_FAILED = 'Sign-in with your identity provider failed. Please contact your administrator for assistance.' INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)." INCORRECT_PASSWORD = 'The password provided is incorrect. Please check for typos and try again.' INVALID_TRUSTED_HEADER = ( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 3e0f9db89f..da2eea353d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -221,6 +221,7 @@ from open_webui.utils.auth import ( get_http_authorization_cred, get_license_data, get_verified_user, + is_valid_token, ) from open_webui.utils.chat import ( chat_completed as chat_completed_handler, @@ -237,6 +238,7 @@ from open_webui.utils.chat_variables import ( normalize_chat_variables, ) from open_webui.utils.embeddings import generate_embeddings +from open_webui.utils.headers import get_headers_and_cookies from open_webui.utils.json_codec import JSONCodec from open_webui.utils.json_response import apply_orjson_http_json from open_webui.utils.logger import start_logger @@ -994,14 +996,12 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) async with session.post( f'{url}/api/generate', data=payload, headers=headers, + cookies=cookies, ) as r: if not r.ok: errors.append({'url_idx': idx, 'error': await r.text()}) @@ -1035,14 +1035,12 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } + headers, cookies = await get_headers_and_cookies(request, base_url, key, api_config, user=user) async with session.post( f'{root_url}/models/unload', json={'model': actual_model}, headers=headers, + cookies=cookies, ) as r: if not r.ok: detail = await r.text() @@ -1490,7 +1488,9 @@ async def chat_completion( asyncio.create_task(run_initial_title_generation()) else: # Existing chat — verify ownership - if not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin': + if not await Chats.is_chat_owner(chat_id, user.id) and not ( + user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS + ): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.DEFAULT(), @@ -1645,7 +1645,8 @@ async def chat_completion( async def process_chat(request, form_data, user, metadata, model, tasks=None): try: ctx = None - if metadata.get('assistant_message_id'): + # Saved chats load the message after approved tool calls run, so their results are kept + if metadata.get('assistant_message_id') and not is_saved_chat_id(metadata.get('chat_id')): ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, []) form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) @@ -2078,7 +2079,7 @@ async def verify_chat_ownership(chat_id: str | None, user) -> None: detail='Channel chats are not supported on this endpoint', ) - if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id): + if not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS) and not await Chats.is_chat_owner(chat_id, user.id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.DEFAULT(), @@ -2142,11 +2143,11 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De socket_id = get_temporary_chat_session_id(chat_id) if socket_id: owner_id = get_user_id_from_session_pool(socket_id) - if owner_id != user.id and user.role != 'admin': + if owner_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): return {'task_ids': []} else: chat = await Chats.get_chat_by_id(chat_id) - if chat is None or (chat.user_id != user.id and user.role != 'admin'): + if chat is None or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)): return {'task_ids': []} task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id) @@ -2161,11 +2162,11 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De chat = None if socket_id: owner_id = get_user_id_from_session_pool(socket_id) - if owner_id != user.id and user.role != 'admin': + if owner_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) else: chat = await Chats.get_chat_by_id(chat_id) - if chat is None or (chat.user_id != user.id and user.role != 'admin'): + if chat is None or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) result = await stop_item_tasks(request.app.state.redis, chat_id) @@ -2242,7 +2243,7 @@ async def get_app_config(request: Request): status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid 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, request.app.state.redis): user = await Users.get_user_by_id(data['id']) onboarding = False diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 8c7f6089e3..0aab02e5f6 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -195,7 +195,7 @@ class AutomationTable: stmt = stmt.filter( or_( Automation.name.ilike(f'%{query}%'), - *(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)), + *(data_text.icontains(variant, autoescape=True) for variant in json_text_variants(query)), ) ) @@ -244,6 +244,15 @@ class AutomationTable: await db.commit() return AutomationModel.model_validate(row) + async def update_last_run_at(self, id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationModel]: + async with get_async_db_context(db) as db: + row = await db.get(Automation, id) + if not row: + return None + row.last_run_at = int(time.time_ns()) + await db.commit() + return AutomationModel.model_validate(row) + async def clear_folder_ids( self, user_id: str, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index d90f52e4c9..c5620f0cfe 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -2522,6 +2522,8 @@ class ChatTable: async def delete_chats_by_user_id_and_folder_id( self, user_id: str, folder_id: str, db: AsyncSession | None = None ) -> bool: + from open_webui.models.shared_chats import SharedChat as SharedChatTable + try: async with get_async_db_context(db) as session: chat_ids_stmt = select(Chat.id).filter_by(user_id=user_id, folder_id=folder_id) @@ -2529,6 +2531,7 @@ class ChatTable: update(AutomationRun).filter(AutomationRun.chat_id.in_(chat_ids_stmt)).values(chat_id=None) ) await session.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(chat_ids_stmt))) + await session.execute(delete(SharedChatTable).filter(SharedChatTable.chat_id.in_(chat_ids_stmt))) await session.execute(delete(Chat).filter_by(user_id=user_id, folder_id=folder_id)) await session.commit() diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 8da346ab70..d48f1e9008 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -392,7 +392,9 @@ class ModelsTable: else: meta_text = func.lower(cast(Model.meta, String)) variants = json_text_variants(tag.lower()) - stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants))) + stmt = stmt.filter( + or_(*(meta_text.contains(f'"{variant}"', autoescape=True) for variant in variants)) + ) order_by = filter.get('order_by') direction = filter.get('direction') diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index f0e4a3e9b8..75cc93b5a8 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -346,7 +346,10 @@ class PromptsTable: # Fallback for dialects with no JSON array function: LIKE on the text. tags_text = func.lower(cast(Prompt.tags, String)) tag_clause = or_( - *(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower)) + *( + tags_text.contains(f'"{variant}"', autoescape=True) + for variant in json_text_variants(tag_lower) + ) ) tag_lower = None diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 0b2d01ff05..a3a3d5b7dd 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -745,7 +745,7 @@ class Loader: ) loader = TextLoader(file_path, encoding=self._detect_text_encoding(file_path)) elif file_ext in ['htm', 'html']: - loader = HTMLLoader(file_path, encoding='unicode_escape') + loader = HTMLLoader(file_path, encoding=self._detect_text_encoding(file_path)) elif file_ext == 'md': loader = TextLoader(file_path, encoding=self._detect_text_encoding(file_path)) elif file_content_type == 'application/epub+zip': diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 055f750122..58a4da1f1b 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -30,6 +30,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, BYPASS_RETRIEVAL_ACCESS_CONTROL, + ENABLE_ADMIN_CHAT_ACCESS, ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS, MPS_INFERENCE_LOCK, @@ -838,6 +839,7 @@ async def query_collection_with_hybrid_search( return name, await ASYNC_VECTOR_DB_CLIENT.get(collection_name=name) except Exception as e: log.exception(f'Failed to fetch collection {name}: {e}') + failed_collection_names.add(name) return name, None collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names))) @@ -1461,7 +1463,9 @@ async def get_sources_from_items( elif item.get('type') == 'chat': # Chat Attached chat = await Chats.get_chat_by_id(item.get('id')) - has_read_access = bool(chat and (user.role == 'admin' or chat.user_id == user.id)) + has_read_access = bool( + chat and ((user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS) or chat.user_id == user.id) + ) if chat and not has_read_access: has_read_access = await AccessGrants.has_access( diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index f8ed600773..d1b302b34f 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -28,6 +28,7 @@ from qdrant_client.http.models import PointStruct from qdrant_client.models import models NO_LIMIT = 999999999 +SCROLL_PAGE_SIZE = 1000 log = logging.getLogger(__name__) @@ -92,6 +93,24 @@ class QdrantClient(VectorDBBase): } ) + def _scroll_points( + self, collection_name: str, scroll_filter: Optional[models.Filter] = None, limit: Optional[int] = None + ) -> list: + # Paged so a strict-mode max_query_limit does not reject the read + points = [] + offset = None + while True: + page_size = SCROLL_PAGE_SIZE if limit is None else min(SCROLL_PAGE_SIZE, limit - len(points)) + page, offset = self.client.scroll( + collection_name=f'{self.collection_prefix}_{collection_name}', + scroll_filter=scroll_filter, + limit=page_size, + offset=offset, + ) + points.extend(page) + if offset is None or len(points) == limit: + return points + def _create_collection(self, collection_name: str, dimension: int): collection_name_with_prefix = f'{self.collection_prefix}_{collection_name}' self.client.create_collection( @@ -180,32 +199,22 @@ class QdrantClient(VectorDBBase): if not self.has_collection(collection_name): return None try: - if limit is None: - limit = NO_LIMIT # otherwise qdrant would set limit to 10! - field_conditions = [] for key, value in filter.items(): field_conditions.append( models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value)) ) - points = self.client.scroll( - collection_name=f'{self.collection_prefix}_{collection_name}', - scroll_filter=models.Filter(should=field_conditions), - limit=limit, - ) - return self._result_to_get_result(points[0]) + points = self._scroll_points(collection_name, models.Filter(should=field_conditions), limit) + return self._result_to_get_result(points) except Exception as e: log.exception(f"Error querying a collection '{collection_name}': {e}") return None def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. - points = self.client.scroll( - collection_name=f'{self.collection_prefix}_{collection_name}', - limit=NO_LIMIT, # otherwise qdrant would set limit to 10! - ) - return self._result_to_get_result(points[0]) + points = self._scroll_points(collection_name) + return self._result_to_get_result(points) def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index 870cd2b02f..6bf71717e2 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -29,7 +29,7 @@ from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.models import PointStruct from qdrant_client.models import models -NO_LIMIT = 999999999 +SCROLL_PAGE_SIZE = 1000 TENANT_ID_FIELD = 'tenant_id' DEFAULT_DIMENSION = 384 @@ -97,6 +97,21 @@ class QdrantClient(VectorDBBase): metadatas.append(payload['metadata']) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + def _scroll_points(self, collection_name: str, scroll_filter: models.Filter, limit: Optional[int] = None) -> List: + # Paged so a strict-mode max_query_limit does not reject the read + points, offset = [], None + while True: + page_size = SCROLL_PAGE_SIZE if limit is None else min(SCROLL_PAGE_SIZE, limit - len(points)) + page, offset = self.client.scroll( + collection_name=collection_name, + scroll_filter=scroll_filter, + limit=page_size, + offset=offset, + ) + points.extend(page) + if offset is None or len(points) == limit: + return points + def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]: """ Maps the traditional collection name to multi-tenant collection and tenant ID. @@ -287,17 +302,11 @@ class QdrantClient(VectorDBBase): if not self.client.collection_exists(collection_name=mt_collection): log.debug("Collection %s doesn't exist, query returns None", mt_collection) return None - if limit is None: - limit = NO_LIMIT tenant_filter = _tenant_filter(tenant_id) field_conditions = [_metadata_filter(k, '$eq', v) for k, v in filter.items()] combined_filter = models.Filter(must=[tenant_filter, *field_conditions]) - points = self.client.scroll( - collection_name=mt_collection, - scroll_filter=combined_filter, - limit=limit, - ) - return self._result_to_get_result(points[0]) + points = self._scroll_points(mt_collection, combined_filter, limit) + return self._result_to_get_result(points) def get(self, collection_name: str) -> Optional[GetResult]: """ @@ -310,12 +319,8 @@ class QdrantClient(VectorDBBase): log.debug("Collection %s doesn't exist, get returns None", mt_collection) return None tenant_filter = _tenant_filter(tenant_id) - points = self.client.scroll( - collection_name=mt_collection, - scroll_filter=models.Filter(must=[tenant_filter]), - limit=NO_LIMIT, - ) - return self._result_to_get_result(points[0]) + points = self._scroll_points(mt_collection, models.Filter(must=[tenant_filter])) + return self._result_to_get_result(points) def upsert(self, collection_name: str, items: List[VectorItem]): """ diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index 7bf15a1e62..a0403787b5 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -14,6 +14,7 @@ def search_tavily( query: str, count: int, filter_list: list[str] | None = None, + search_depth: str = 'basic', # **kwargs, ) -> list[SearchResult]: """Search using Tavily's Search API and return the results as a list of SearchResult objects. @@ -22,6 +23,7 @@ def search_tavily( api_key (str): A Tavily Search API key query (str): The query to search for count (int): The maximum number of results to return + search_depth (str): Tavily search depth Returns: A list of SearchResult objects. @@ -31,7 +33,7 @@ def search_tavily( 'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}', } - data = {'query': query, 'max_results': count} + data = {'query': query, 'max_results': count, 'search_depth': search_depth} response = requests.post(url, headers=headers, json=data) response.raise_for_status() diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 4b44a8b8ed..f5ab9b9536 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -341,6 +341,7 @@ async def run_automation_by_id( await check_automations_permission(request, user) automation = await Automations.get_by_id(id, db=db) check_automation_access(automation, user) + automation = await Automations.update_last_run_at(automation.id, db=db) asyncio.create_task(execute_automation(request.app, automation)) await publish_event( request, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index c9d38bf2ce..2a16e23be2 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -642,6 +642,9 @@ async def add_members_by_id( if channel.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + if channel.type == 'dm': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + try: memberships = await Channels.add_members_to_channel( channel.id, user.id, form_data.user_ids, form_data.group_ids, db=db @@ -686,9 +689,12 @@ async def remove_members_by_id( if channel.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + if channel.type == 'dm': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + try: deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db) - if channel.type in ['group', 'dm']: + if channel.type == 'group': await leave_room_for_users(f'channel:{channel.id}', form_data.user_ids) await publish_event( diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7138dc9f21..e6194d0f64 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -680,7 +680,7 @@ async def export_single_chat_stats( ) # Verify the chat belongs to the user (unless admin) - if chat.user_id != user.id and user.role != 'admin': + if chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1428,7 +1428,7 @@ async def update_chat_message_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if chat.user_id != user.id and user.role != 'admin': + if chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1489,7 +1489,7 @@ async def delete_chat_message_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if chat.user_id != user.id and user.role != 'admin': + if chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1537,7 +1537,7 @@ async def send_chat_message_event_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if chat.user_id != user.id and user.role != 'admin': + if chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1584,6 +1584,8 @@ async def delete_chat_by_id( # not be reachable for a chat the caller may not delete. if user.role == 'admin': chat = await Chats.get_chat_by_id(id, db=db) + if chat and chat.user_id != user.id and not ENABLE_ADMIN_CHAT_ACCESS: + chat = None else: if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')): raise HTTPException( @@ -1848,21 +1850,9 @@ async def clone_shared_chat_by_id( ): 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 - if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: - chat = await Chats.get_chat_by_id(id, db=db) - - if not chat: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - # Enforce access grants (owner and admins bypass) shared = await SharedChats.get_by_id(id, db=db) - if shared and user.role != 'admin' and shared.user_id != user.id: + if shared and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS) and shared.user_id != user.id: has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access( user_id=user.id, resource_type='shared_chat', @@ -1876,6 +1866,18 @@ async def clone_shared_chat_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + chat = await Chats.get_chat_by_share_id(id, db=db) if shared else None + + # Fallback: admins can also access any chat directly by chat ID + if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + updated_chat = { **chat.chat, 'originalChatId': chat.id, @@ -2042,7 +2044,7 @@ async def update_shared_chat_access_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if user.role == 'admin': + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(id, db=db) else: chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) @@ -2078,7 +2080,7 @@ async def get_shared_chat_access_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if user.role == 'admin': + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(id, db=db) else: chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index c894f2c651..28a037a993 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -7,6 +7,7 @@ import logging import mimetypes import re import uuid +from contextlib import nullcontext from pathlib import Path from types import SimpleNamespace from typing import Optional @@ -488,14 +489,18 @@ async def get_image_data(data: str, headers=None, trusted_base_url: str | None = # that would follow arbitrary redirects. if trusted_base_url and _is_same_origin(data, trusted_base_url): log.debug('Skipping URL validation for trusted backend: %s', data) + session_context = nullcontext(await get_session()) else: await asyncio.to_thread(validate_url, data) - session = await get_session() - async with session.get( - data, - headers=headers, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as r: + session_context = get_ssrf_safe_session() + async with ( + session_context as session, + session.get( + data, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r, + ): r.raise_for_status() content_type = r.headers.get('content-type', '') if content_type.split('/')[0] == 'image': diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index b283db9ae8..d354986d37 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -22,8 +22,6 @@ from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, AIOHTTP_FILE_STREAM_CHUNK_SIZE, BYPASS_MODEL_ACCESS_CONTROL, - ENABLE_FORWARD_USER_INFO_HEADERS, - FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, REDIS_KEY_PREFIX, ) @@ -36,7 +34,7 @@ from open_webui.models.models import Models from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access 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 +from open_webui.utils.headers import get_headers_and_cookies from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import calculate_sha256 from open_webui.utils.model_ids import strip_provider_model_prefix @@ -67,24 +65,21 @@ def _clean_proxy_headers(raw_headers) -> dict: async def send_get_request( - url: str, - key: str | None = None, - user: UserModel | None = None, + request: Request = None, + url=None, + key=None, + user: UserModel = None, + config=None, ): """Issue a GET request to an Ollama backend and return JSON, or *None* on failure.""" try: session = await get_session() - headers: dict = { - 'Content-Type': 'application/json', - } - if key: - headers['Authorization'] = f'Bearer {key}' - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) + headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user) async with session.get( url, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_MODEL_LIST_TIMEOUT, ) as r: @@ -114,25 +109,14 @@ async def send_request( try: session = await get_session() - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user, request=request) - if metadata and metadata.get('chat_id'): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') - - # Custom per-connection headers last so admin-set headers take precedence. - if api_config and api_config.get('headers'): - headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request)) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, metadata, user=user) r = await session.request( method, url, data=payload, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=get_client_timeout(stream=stream), ) @@ -249,24 +233,26 @@ class ConnectionVerificationForm(BaseModel): url: str key: str | None = None + config: dict | None = None + @router.post('/verify') async def verify_connection( + request: Request, form_data: ConnectionVerificationForm, user=Depends(get_admin_user), ): """Verify that an Ollama backend at *form_data.url* is reachable.""" try: session = await get_session() - headers: dict = {} - if form_data.key: - headers['Authorization'] = f'Bearer {form_data.key}' - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) + headers, cookies = await get_headers_and_cookies( + request, form_data.url, form_data.key, form_data.config, user=user + ) async with session.get( f'{form_data.url}/api/version', headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_MODEL_LIST_TIMEOUT, ) as r: @@ -403,9 +389,11 @@ async def get_all_models(request: Request, user: UserModel | None = None): for idx, url in enumerate(base_urls): api_config = resolve_api_config(api_configs, idx, url) if not api_config: - tasks.append(send_get_request(f'{url}/api/tags', user=user)) + tasks.append(send_get_request(request, f'{url}/api/tags', user=user)) elif api_config.get('enable', True): - tasks.append(send_get_request(f'{url}/api/tags', api_config.get('key'), user=user)) + tasks.append( + send_get_request(request, f'{url}/api/tags', api_config.get('key'), user=user, config=api_config) + ) else: tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -495,9 +483,15 @@ async def get_ollama_tags( if url_idx is None: result = await get_all_models(request, user=user) else: - 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) + url, api_config, key = await get_ollama_connection(url_idx) + result = await send_request( + f'{url}/api/tags', + 'GET', + key=key, + user=user, + api_config=api_config, + request=request, + ) if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: result['models'] = await get_filtered_models(result, user) @@ -524,9 +518,11 @@ async def get_ollama_loaded_models( continue api_config = resolve_api_config(api_configs, idx, url) if not api_config: - tasks.append(send_get_request(f'{url}/api/ps', user=user)) + tasks.append(send_get_request(request, f'{url}/api/ps', user=user)) elif api_config.get('enable', True): - tasks.append(send_get_request(f'{url}/api/ps', api_config.get('key'), user=user)) + tasks.append( + send_get_request(request, f'{url}/api/ps', api_config.get('key'), user=user, config=api_config) + ) else: tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -559,8 +555,15 @@ async def get_ollama_versions( return {'version': False} if url_idx is not None: - url = (await Config.get('ollama.base_urls', []))[url_idx] - return await send_request(f'{url}/api/version', 'GET') + url, api_config, key = await get_ollama_connection(url_idx) + return await send_request( + f'{url}/api/version', + 'GET', + key=key, + user=user, + api_config=api_config, + request=request, + ) # Fan-out to every enabled backend tasks = [] @@ -570,7 +573,9 @@ async def get_ollama_versions( (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'))) + tasks.append( + send_get_request(request, f'{url}/api/version', api_config.get('key'), user=user, config=api_config) + ) raw = await asyncio.gather(*tasks) valid = [r for r in raw if r is not None] @@ -634,6 +639,8 @@ async def unload_model( payload=JSONCodec.dumps(payload), key=key, user=user, + api_config=api_config, + request=request, ) results.append({'url_idx': idx, 'success': True, 'response': res}) except Exception as e: @@ -663,17 +670,19 @@ async def pull_model( form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) - url = (await Config.get('ollama.base_urls', []))[url_idx] + url, api_config, key = await get_ollama_connection(url_idx) log.info('url: %s', url) # Admins may pull from any registry return await send_request( f'{url}/api/pull', payload=JSONCodec.dumps({**form_data, 'insecure': True}), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=key, user=user, stream=True, passthrough=True, + api_config=api_config, + request=request, ) @@ -704,16 +713,18 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx] + url, api_config, key = await get_ollama_connection(url_idx) log.debug('url: %s', 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, (await Config.get('ollama.api_configs', {}))), + key=key, user=user, stream=True, passthrough=True, + api_config=api_config, + request=request, ) @@ -738,15 +749,17 @@ async def create_model( raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.debug('form_data: %s', form_data) - url = (await Config.get('ollama.base_urls', []))[url_idx] + url, api_config, key = await get_ollama_connection(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, (await Config.get('ollama.api_configs', {}))), + key=key, user=user, stream=True, passthrough=True, + api_config=api_config, + request=request, ) @@ -776,14 +789,15 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx] - key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) + url, api_config, key = await get_ollama_connection(url_idx) await send_request( f'{url}/api/copy', payload=form_data.model_dump_json(exclude_none=True).encode(), key=key, user=user, + api_config=api_config, + request=request, ) await publish_event( request, @@ -818,8 +832,7 @@ async def delete_model( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) url_idx = models[model]['urls'][0] - url = (await Config.get('ollama.base_urls', []))[url_idx] - key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) + url, api_config, key = await get_ollama_connection(url_idx) await send_request( f'{url}/api/delete', @@ -827,6 +840,8 @@ async def delete_model( payload=JSONCodec.dumps(payload), key=key, user=user, + api_config=api_config, + request=request, ) await publish_event( request, @@ -861,14 +876,15 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx] - key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) + url, api_config, key = await get_ollama_connection(url_idx) return await send_request( f'{url}/api/show', payload=JSONCodec.dumps(payload), key=key, user=user, + api_config=api_config, + request=request, ) @@ -922,6 +938,8 @@ async def embed( payload=form_data.model_dump_json(exclude_none=True).encode(), key=key, user=user, + api_config=api_config, + request=request, ) @@ -973,6 +991,8 @@ async def embeddings( payload=form_data.model_dump_json(exclude_none=True).encode(), key=key, user=user, + api_config=api_config, + request=request, ) @@ -1030,6 +1050,8 @@ async def generate_completion( user=user, stream=True, passthrough=True, + api_config=api_config, + request=request, ) @@ -1501,8 +1523,15 @@ async def get_openai_models( model_list = await get_all_models(request, user=user) raw_models = model_list['models'] else: - url = (await Config.get('ollama.base_urls', []))[url_idx] - model_list = await send_request(f'{url}/api/tags', 'GET') + url, api_config, key = await get_ollama_connection(url_idx) + model_list = await send_request( + f'{url}/api/tags', + 'GET', + key=key, + user=user, + api_config=api_config, + request=request, + ) raw_models = model_list.get('models', []) now_ts = int(time.time()) @@ -1552,6 +1581,8 @@ async def download_file_stream( file_url: str, file_path: str, file_name: str, + ollama_headers: dict, + ollama_cookies: dict, chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE, ): """Stream a model file download from *file_url*, then push the blob to Ollama.""" @@ -1590,7 +1621,12 @@ async def download_file_stream( async with session.post( blob_url, data=blob_chunks(), - headers={'Content-Length': str(blob_size)}, + headers={ + **ollama_headers, + 'Content-Type': 'application/octet-stream', + 'Content-Length': str(blob_size), + }, + cookies=ollama_cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=aiohttp.ClientTimeout(total=30), ) as blob_resp: @@ -1617,15 +1653,17 @@ async def download_model( detail='Invalid file_url. Only URLs from allowed hosts are permitted.', ) - url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] + url, api_config, key = await get_ollama_connection(url_idx if url_idx is not None else 0) file_name = parse_huggingface_url(form_data.url) if not file_name: return None + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) + file_path = os.path.join(UPLOAD_DIR, file_name) return StreamingResponse( - download_file_stream(url, form_data.url, file_path, file_name), + download_file_stream(url, form_data.url, file_path, file_name, headers, cookies), ) @@ -1638,7 +1676,8 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] + ollama_url, api_config, key = await get_ollama_connection(url_idx if url_idx is not None else 0) + headers, cookies = await get_headers_and_cookies(request, ollama_url, key, api_config, user=user) filename = os.path.basename(file.filename) file_path = os.path.join(UPLOAD_DIR, filename) @@ -1680,7 +1719,12 @@ async def upload_model( async with session.post( blob_url, data=blob_chunks(), - headers={'Content-Length': str(total_size)}, + headers={ + **headers, + 'Content-Type': 'application/octet-stream', + 'Content-Length': str(total_size), + }, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=get_client_timeout(), ) as resp: @@ -1702,7 +1746,8 @@ async def upload_model( async with session.post( f'{ollama_url}/api/create', - headers={'Content-Type': 'application/json'}, + headers=headers, + cookies=cookies, data=JSONCodec.dumps(create_payload), ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=get_client_timeout(), diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 63676d166e..b8a75c2bfb 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -10,7 +10,6 @@ from urllib.parse import quote, urlparse import aiofiles import aiohttp from aiocache import cached -from azure.identity import DefaultAzureCredential, get_bearer_token_provider from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import ( FileResponse, @@ -28,7 +27,6 @@ from open_webui.env import ( BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_OPENAI_API_PASSTHROUGH, - FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, REDIS_KEY_PREFIX, ) @@ -42,7 +40,7 @@ from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access, has_connection_access, has_permission from open_webui.utils.anthropic import ANTHROPIC_VERSION, 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 +from open_webui.utils.headers import get_headers_and_cookies, include_user_info_headers from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import convert_logit_bias_input_to_json from open_webui.utils.model_ids import strip_provider_model_prefix @@ -152,87 +150,6 @@ def openai_reasoning_model_handler(payload): return payload -async def get_headers_and_cookies( - request: Request, - url, - key=None, - config=None, - metadata: dict | None = None, - user: UserModel = None, -): - cookies = getattr(request, 'cookies', {}) if config.get('forward_cookies', False) else {} - headers = { - 'Content-Type': 'application/json', - **( - { - # LICENSE covers this Open WebUI upstream metadata identifier. - # Do not alter, remove, obscure, or replace it except as LICENSE permits: - # https://docs.openwebui.com/license. - 'HTTP-Referer': 'https://openwebui.com/', - 'X-Title': 'Open WebUI', - } - if 'openrouter.ai' in url - else {} - ), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user, request=request) - if metadata and metadata.get('chat_id'): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') - - token = None - auth_type = config.get('auth_type') - - if auth_type == 'bearer' or auth_type is None: - # Default to bearer if not specified - token = f'{key}' - elif auth_type == 'none': - token = None - elif auth_type == 'session': - token = request.state.token.credentials - elif auth_type == 'system_oauth': - oauth_token = None - try: - if request.cookies.get('oauth_session_id', None): - oauth_token = await request.app.state.oauth_manager.get_oauth_token( - user.id, - request.cookies.get('oauth_session_id', None), - ) - except Exception as e: - log.error(f'Error getting OAuth token: {e}') - - if oauth_token: - token = f'{oauth_token.get("access_token", "")}' - - elif auth_type in ('azure_ad', 'microsoft_entra_id'): - token = get_microsoft_entra_id_access_token() - - if token: - headers['Authorization'] = f'Bearer {token}' - - if config.get('headers') and isinstance(config.get('headers'), dict): - custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request) - headers.update(custom_headers) - - return headers, cookies - - -def get_microsoft_entra_id_access_token(): - """ - Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI. - Returns the token string or None if authentication fails. - """ - try: - token_provider = get_bearer_token_provider( - DefaultAzureCredential(), 'https://cognitiveservices.azure.com/.default' - ) - return token_provider() - except Exception as e: - log.error(f'Error getting Microsoft Entra ID access token: {e}') - return None - - ########################################## # # API routes diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index b9375e56c6..401eeb079a 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -387,6 +387,7 @@ RETRIEVAL_CONFIG_KEYS = { 'STAAN_MAX_SNIPPETS': 'web.search.staan_max_snippets', 'TAVILY_API_KEY': 'web.search.tavily_api_key', 'TAVILY_EXTRACT_DEPTH': 'web.search.tavily_extract_depth', + 'TAVILY_SEARCH_DEPTH': 'web.search.tavily_search_depth', 'TEXT_SPLITTER': 'rag.text_splitter', 'TIKA_SERVER_URL': 'rag.tika_server_url', 'TIKA_SERVER_VERSION': 'rag.tika_server_version', @@ -773,6 +774,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'FIRECRAWL_API_BASE_URL': config.FIRECRAWL_API_BASE_URL, 'FIRECRAWL_TIMEOUT': config.FIRECRAWL_TIMEOUT, 'TAVILY_EXTRACT_DEPTH': config.TAVILY_EXTRACT_DEPTH, + 'TAVILY_SEARCH_DEPTH': config.TAVILY_SEARCH_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, @@ -856,6 +858,7 @@ class WebConfig(BaseModel): FIRECRAWL_API_BASE_URL: str | None = None FIRECRAWL_TIMEOUT: str | None = None TAVILY_EXTRACT_DEPTH: str | None = None + TAVILY_SEARCH_DEPTH: str | None = None EXTERNAL_WEB_SEARCH_URL: str | None = None EXTERNAL_WEB_SEARCH_API_KEY: str | None = None EXTERNAL_WEB_LOADER_URL: str | None = None @@ -1386,6 +1389,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 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.TAVILY_SEARCH_DEPTH = form_data.web.TAVILY_SEARCH_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 @@ -1535,6 +1539,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'FIRECRAWL_API_BASE_URL': config.FIRECRAWL_API_BASE_URL, 'FIRECRAWL_TIMEOUT': config.FIRECRAWL_TIMEOUT, 'TAVILY_EXTRACT_DEPTH': config.TAVILY_EXTRACT_DEPTH, + 'TAVILY_SEARCH_DEPTH': config.TAVILY_SEARCH_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, @@ -2376,7 +2381,16 @@ async def process_url( } config = await get_retrieval_config() - url_result = await _fetch_url(form_data.url, config.FILE_MAX_SIZE) + try: + url_result = await _fetch_url(form_data.url, config.FILE_MAX_SIZE) + except HTTPException: + raise + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e, f'Could not read content from {form_data.url}'), + ) if url_result['kind'] == 'web': result = await process_web(request, form_data, process=process, user=user) @@ -2457,6 +2471,13 @@ async def process_web( detail=ERROR_MESSAGES.DEFAULT(e, f'Could not read content from {form_data.url}'), ) + # web loaders swallow fetch errors and return no documents + if not docs: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(f'Could not read content from {form_data.url}'), + ) + try: log.debug('text_content: %s', content) @@ -2705,6 +2726,7 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li query, config.WEB_SEARCH_RESULT_COUNT, config.WEB_SEARCH_DOMAIN_FILTER_LIST, + search_depth=config.TAVILY_SEARCH_DEPTH, ) else: raise Exception('No TAVILY_API_KEY found in environment variables') diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index f157ed2f81..dbfb0f417d 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -36,6 +36,7 @@ from open_webui.env import ( from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels 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.notes import Notes, NoteUpdateForm from open_webui.models.users import UserNameResponse, Users @@ -392,8 +393,12 @@ async def enter_room_for_users(room: str, user_ids: list[str]): user_ids (list[str]): The target user's IDs. """ try: - for user_id in user_ids: - session_ids = get_session_ids_from_room(f'user:{user_id}') + default_permissions = await Config.get('user.permissions') + for user in await Users.get_users_by_user_ids(user_ids): + if user.role != 'admin' and not await has_permission(user.id, 'features.channels', default_permissions): + continue + + session_ids = get_session_ids_from_room(f'user:{user.id}') for sid in session_ids: await sio.enter_room(sid, room) except Exception as e: @@ -501,7 +506,7 @@ async def user_join(sid, data): await sio.enter_room(sid, f'user:{user.id}') # Join all the channels only if user has channels permission - if user.role == 'admin' or await has_permission(user.id, 'features.channels'): + if user.role == 'admin' or await has_permission(user.id, 'features.channels', await Config.get('user.permissions')): channels = await Channels.get_channels_by_user_id(user.id) log.debug('channels=%r', channels) for channel in channels: @@ -533,7 +538,7 @@ async def join_channel(sid, data): return # Join all the channels only if user has channels permission - if user.role == 'admin' or await has_permission(user.id, 'features.channels'): + if user.role == 'admin' or await has_permission(user.id, 'features.channels', await Config.get('user.permissions')): channels = await Channels.get_channels_by_user_id(user.id) log.debug('channels=%r', channels) for channel in channels: diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 36237bdaeb..bd2cea2e61 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -432,8 +432,8 @@ def convert_anthropic_to_openai_payload( else: openai_payload[param] = anthropic_payload[param] - # Tools conversion: Anthropic → OpenAI - if 'tools' in anthropic_payload: + # Tools conversion: Anthropic → OpenAI (backends reject an empty tools array) + if anthropic_payload.get('tools'): openai_tools = [] for tool in anthropic_payload['tools']: openai_tools.append( @@ -452,7 +452,7 @@ def convert_anthropic_to_openai_payload( openai_payload['tools'] = openai_tools # tool_choice - if 'tool_choice' in anthropic_payload: + if 'tool_choice' in anthropic_payload and 'tools' in openai_payload: tool_choice = anthropic_payload['tool_choice'] if isinstance(tool_choice, dict): tool_choice_type = tool_choice.get('type', 'auto') @@ -616,6 +616,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str server_tool_use = None service_tier = None stop_reason = 'end_turn' + error_message = None # Track content blocks with a running index. # Each text block or tool_use block gets its own index. @@ -671,6 +672,13 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str except (JSONCodec.JSONDecodeError, TypeError): continue + error = data.get('error') + if error: + error_message = ( + error.get('message') if isinstance(error, dict) else error + ) or 'Chat completion stream failed' + break + usage_data = data.get('usage') if isinstance(usage_data, dict): cache_creation = usage_data.get('cache_creation_input_tokens') @@ -904,8 +912,18 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str } stop_reason = stop_reason_map.get(finish_reason, 'end_turn') + if error_message: + break + except Exception as e: log.error(f'Error in Anthropic stream conversion: {e}') + error_message = 'Chat completion stream failed' + + # Skip message_stop so a failed stream is not reported as complete. + if error_message: + error_event = {'type': 'error', 'error': {'type': 'api_error', 'message': error_message}} + yield f'event: error\ndata: {JSONCodec.dumps(error_event)}\n\n'.encode() + return # Close any open thinking block if thinking_block_open: diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 7354b2c641..177036acb6 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -282,11 +282,12 @@ class AuditLoggingMiddleware: response_body = context.response_body.decode('utf-8', errors='replace') # Redact sensitive information - if 'password' in request_body: + if 'password' in request_body.lower(): request_body = re.sub( - r'"password":\s*"(.*?)"', - '"password": "********"', + r'"(\w*password)":\s*".*?"', + r'"\1": "********"', request_body, + flags=re.IGNORECASE, ) entry = AuditLogEntry( diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index be5d6868f1..d6c0f4dfe4 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -261,6 +261,13 @@ async def _execute_channel_automation( if not channel_id or not await Config.get('channels.enable'): raise ValueError('Channel not found') + from open_webui.utils.access_control import has_permission + + if user.role != 'admin' and not await has_permission( + user.id, 'features.channels', await Config.get('user.permissions') + ): + raise ValueError('Owner no longer permitted to use channels') + model = getattr(app.state, 'MODELS', {}).get(model_id, {}) request = _build_request(app, token=token) diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py index 0406db38f6..063cbc8e3c 100644 --- a/backend/open_webui/utils/calendar.py +++ b/backend/open_webui/utils/calendar.py @@ -40,7 +40,6 @@ def expand_recurring_event( range_start = to_local_datetime(range_start_ns) range_end = to_local_datetime(range_end_ns) - scan_start = range_start - dt.timedelta(days=1) original_start_ns = event_dict['start_at'] original_start = to_local_datetime(original_start_ns) @@ -55,6 +54,9 @@ def expand_recurring_event( original_end_ns = event_dict.get('end_at') duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None + # Look back by the event length so occurrences still running at range start are found + event_length = dt.timedelta(microseconds=max(duration_ns or 0, 0) // 1000) + scan_start = range_start - dt.timedelta(days=1) - event_length instances = [] previous_start = None @@ -64,12 +66,13 @@ def expand_recurring_event( previous_start = occurrence_start instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) + instance_end_ns = (instance_start_ns + duration_ns) if duration_ns else None - if instance_start_ns >= range_start_ns: + if instance_start_ns >= range_start_ns or (instance_end_ns and instance_end_ns > range_start_ns): instance = { **event_dict, 'start_at': instance_start_ns, - 'end_at': (instance_start_ns + duration_ns) if duration_ns else None, + 'end_at': instance_end_ns, 'instance_id': f'{event_dict["id"]}_{instance_start_ns}', } instances.append(instance) diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 91cd088834..75368a43ef 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -5,7 +5,10 @@ from typing import Any, Optional from urllib.parse import quote import jwt +from fastapi import Request from open_webui.env import ( + ENABLE_FORWARD_USER_INFO_HEADERS, + FORWARD_SESSION_INFO_HEADER_CHAT_ID, FORWARD_USER_INFO_HEADER_AUTH_TYPE, FORWARD_USER_INFO_HEADER_JWT, FORWARD_USER_INFO_HEADER_JWT_EXPIRES_SECONDS, @@ -161,3 +164,87 @@ def parse_custom_headers( parsed_headers[key] = quote(value, safe=punctuation + ' \t') return parsed_headers + + +async def get_headers_and_cookies( + request: Request, + url, + key=None, + config=None, + metadata: dict | None = None, + user=None, +): + config = config or {} + cookies = getattr(request, 'cookies', {}) if config.get('forward_cookies', False) else {} + headers = { + 'Content-Type': 'application/json', + **( + { + # LICENSE covers this Open WebUI upstream metadata identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. + 'HTTP-Referer': 'https://openwebui.com/', + 'X-Title': 'Open WebUI', + } + if 'openrouter.ai' in url + else {} + ), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user, request=request) + if metadata and metadata.get('chat_id'): + headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') + + token = None + auth_type = config.get('auth_type') + + if auth_type == 'bearer' or auth_type is None: + # Default to bearer if not specified + token = key + elif auth_type == 'none': + token = None + elif auth_type == 'session': + token = request.state.token.credentials + elif auth_type == 'system_oauth': + oauth_token = None + try: + if request.cookies.get('oauth_session_id', None): + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get('oauth_session_id', None), + ) + except Exception as e: + log.error(f'Error getting OAuth token: {e}') + + if oauth_token: + token = f'{oauth_token.get("access_token", "")}' + + elif auth_type in ('azure_ad', 'microsoft_entra_id'): + token = get_microsoft_entra_id_access_token() + + if token: + headers['Authorization'] = f'Bearer {token}' + + if config.get('headers') and isinstance(config.get('headers'), dict): + custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request) + headers.update(custom_headers) + + return headers, cookies + + +def get_microsoft_entra_id_access_token(): + """ + Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI. + Returns the token string or None if authentication fails. + """ + from azure.identity import DefaultAzureCredential, get_bearer_token_provider + + try: + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), 'https://cognitiveservices.azure.com/.default' + ) + return token_provider() + except Exception as e: + log.error(f'Error getting Microsoft Entra ID access token: {e}') + return None diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index b70c59aef2..679785a57c 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -1,6 +1,7 @@ import asyncio import logging from contextlib import AsyncExitStack +from datetime import timedelta from typing import Any, Optional log = logging.getLogger(__name__) @@ -135,7 +136,11 @@ class MCPClient: if not self.session: raise RuntimeError('MCP client is not connected.') - result = await self.session.call_tool(function_name, function_args) + tool_call_timeout = None + if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None and AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER > 0: + tool_call_timeout = timedelta(seconds=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) + + result = await self.session.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout) if not result: raise Exception('No result returned from MCP tool call.') diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 754b90c0c0..6a3e4b6f5b 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -115,6 +115,7 @@ from open_webui.utils.misc import ( get_last_user_message_item, get_message_list, get_output_text, + get_paired_tool_call_ids, get_response_error_detail, get_reasoning_details, get_system_message, @@ -509,6 +510,7 @@ def get_citation_source_from_tool_result( }, 'document': [], 'metadata': [], + 'distances': [], } sources_by_file[key]['document'].append(content) @@ -520,6 +522,8 @@ def get_citation_source_from_tool_result( **({'note_id': note_id} if note_id else {}), } ) + if 'distance' in chunk: + sources_by_file[key]['distances'].append(chunk['distance']) # Return all grouped sources as a list if sources_by_file: @@ -933,6 +937,9 @@ def handle_responses_streaming_event( error = data.get('response', {}).get('error', {}) return current_output, {'error': error} + elif event_type == 'error': + return current_output, {'error': data} + else: return current_output, None @@ -1767,19 +1774,10 @@ async def get_image_urls(delta_images, request, metadata, user) -> list[str]: return image_urls -async def add_file_context(messages: list, chat_id: str, user) -> list: +def add_file_context(messages: list) -> list: """ Add file URLs to messages for native function calling. """ - if not is_saved_chat_id(chat_id): - return messages - - chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) - if not chat: - return messages - - history = chat.chat.get('history', {}) - stored_messages = get_message_list(history.get('messages', {}), history.get('currentId')) def format_file_tag(file): # Every file reaching here has a url or a chat id, so id is always set. @@ -1792,20 +1790,13 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: attrs += f' name="{file["name"]}"' return f'' - # Pair only user-role messages from both lists to avoid misalignment. - # After process_messages_with_output(), assistant messages with tool calls - # are expanded into multiple messages (assistant + tool results), making - # the payload message list longer than the stored message list. A naive - # positional zip() would pair user messages with wrong stored messages, - # causing later images to lose their file context (see #21878). - user_messages = [m for m in messages if m.get('role') == 'user'] - stored_user_messages = [m for m in stored_messages if m.get('role') == 'user'] - - for message, stored_message in zip(user_messages, stored_user_messages): + for message in messages: + if message.get('role') != 'user': + continue # Chat references carry no url - they are addressed by id via view_chat. attached_files = [ file - for file in stored_message.get('files', []) + for file in message.get('files', []) if (file.get('url') and not file.get('url').startswith('data:')) or (file.get('type') == 'chat' and file.get('id')) ] @@ -2270,6 +2261,7 @@ def strip_reasoning_details(output: list) -> list: def process_messages_with_output( messages: list[dict], reasoning_format: str | None = None, + include_file_context: bool = False, ) -> list[dict]: """ Process messages with OR-aligned output items for LLM consumption. @@ -2295,33 +2287,25 @@ def process_messages_with_output( continue clean_message = dict(message) - for key in ('id', 'files', 'output', 'model', 'contextSummary', 'context_summary', 'usage'): + for key in ('id', 'output', 'model', 'contextSummary', 'context_summary', 'usage'): clean_message.pop(key, None) processed.append(clean_message) + if include_file_context: + add_file_context(processed) + for message in processed: + message.pop('files', None) + return processed def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: - tool_result_ids = { - message.get('tool_call_id') - for message in messages - if message.get('role') == 'tool' and message.get('tool_call_id') - } - - tool_call_ids = { - tool_call.get('id') - for message in messages - for tool_call in (message.get('tool_calls') or []) - if message.get('role') == 'assistant' and tool_call.get('id') - } + paired_ids_by_message = get_paired_tool_call_ids(messages) sanitized = [] - for message in messages: + for message, paired_ids in zip(messages, paired_ids_by_message): if message.get('role') == 'assistant' and message.get('tool_calls'): - kept = [ - tool_call for tool_call in message.get('tool_calls') or [] if tool_call.get('id') in tool_result_ids - ] + kept = [tool_call for tool_call in message.get('tool_calls') or [] if tool_call.get('id') in paired_ids] if kept: sanitized.append({**message, 'tool_calls': kept}) else: @@ -2330,7 +2314,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: clean.pop('reasoning_items', None) if clean.get('content'): sanitized.append(clean) - elif message.get('role') != 'tool' or message.get('tool_call_id') in tool_call_ids: + elif message.get('role') != 'tool' or message.get('tool_call_id') in paired_ids: sanitized.append(message) return sanitized @@ -2438,6 +2422,17 @@ async def process_chat_payload(request, form_data, user, metadata, model): # which the frontend strips, causing tool calls to be merged into content. chat_id = metadata.get('chat_id') user_message_id = metadata.get('user_message_id') + payload_tools = form_data.get('tools', None) # snapshot before filters + chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None + is_note_chat = bool(chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') + use_builtin_tools = is_note_chat or ( + bool(metadata.get('session_id')) + and metadata.get('params', {}).get('function_calling') != 'legacy' + and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True) + ) + metadata['include_file_context'] = bool( + chat and chat.user_id == user.id and payload_tools is None and use_builtin_tools + ) if is_saved_chat_id(chat_id) and user_message_id: db_messages = await load_messages_from_db(chat_id, user_message_id) @@ -2474,8 +2469,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): if f.get('url') ], ] - # Strip files field — it's been incorporated into content - message.pop('files', None) if regeneration_prompt: form_data['messages'].append({'role': 'user', 'content': regeneration_prompt}) @@ -2511,6 +2504,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception: log.exception('Context compaction failed; continuing with full chat history') + metadata['context_start_message_id'] = next( + (message.get('id') for message in form_data.get('messages', []) if message.get('role') != 'system'), None + ) + # Process messages with OR-aligned output items for clean LLM messages for message in form_data.get('messages', []): output = message.get('output') @@ -2522,6 +2519,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['messages'] = process_messages_with_output( form_data.get('messages', []), reasoning_format=get_reasoning_format(model), + include_file_context=metadata['include_file_context'], ) form_data['messages'] = sanitize_tool_pairs(form_data['messages']) @@ -2643,7 +2641,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['files'] = files variables = form_data.pop('variables', None) - payload_tools = form_data.get('tools', None) # snapshot before filters # Process the form_data through the pipeline try: @@ -2776,12 +2773,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): available_skills = [] terminal_skills = [] view_skill_ids = [] - chat = None - if is_saved_chat_id(metadata.get('chat_id')): - chat = await Chats.get_chat_by_id(metadata['chat_id']) - - is_note_chat = bool(chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') - if is_note_chat: note_id = (chat.meta or {}).get('note_id') note = await Notes.get_note_by_id(note_id) if note_id else None @@ -2805,12 +2796,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): if note_files: files = [*(files or []), *note_files] - use_builtin_tools = is_note_chat or ( - bool(metadata.get('session_id')) - and metadata.get('params', {}).get('function_calling') != 'legacy' - and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True) - ) - if skill_ids or use_builtin_tools: from open_webui.models.skills import Skills as SkillsModel from open_webui.utils.terminals import ( @@ -3048,6 +3033,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): for tool_server in direct_tool_servers: if tool_server.get('is_terminal') is True and not terminal_capability: continue + # Copy so the pops below keep metadata intact for sub-agents and approval resumes + tool_server = dict(tool_server) system_prompt = tool_server.pop('system_prompt', None) if system_prompt: form_data['messages'] = add_or_update_system_message( @@ -3079,10 +3066,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Only inject when the request originates from the UI (identified by session_id). # API callers don't expect hidden tools; they can explicitly request tools via tool_ids. if use_builtin_tools: - # 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) - if (model.get('info', {}).get('meta', {}).get('builtinTools') or {}).get('knowledge', True): from html import escape @@ -3544,6 +3527,11 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) if assistant_message: db_messages.append({k: v for k, v in assistant_message.items() if k in MESSAGE_REPLAY_KEYS}) + context_start_message_id = metadata.get('context_start_message_id') + start_index = next( + (index for index, message in enumerate(db_messages) if message.get('id') == context_start_message_id), 0 + ) + db_messages = db_messages[start_index:] for message in db_messages: output = message.get('output') # reasoning_details can be model/provider-bound, so only replay them @@ -3555,9 +3543,11 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - ): message['output'] = strip_reasoning_details(output) + system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = process_messages_with_output( - db_messages, + [system_message, *db_messages] if system_message else db_messages, reasoning_format=get_reasoning_format(model), + include_file_context=metadata.get('include_file_context', False), ) form_data['messages'] = sanitize_tool_pairs(form_data['messages']) @@ -3612,7 +3602,7 @@ async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[di if not has_pending_approval: item['status'] = 'pending' has_pending_approval = True - elif item.get('status') == 'in_progress': + elif item.get('status') in {'in_progress', 'completed'}: item['status'] = 'queued' await Chats.upsert_message_to_chat_by_id_and_message_id( @@ -4549,7 +4539,7 @@ async def streaming_chat_response_handler(response, ctx): last_type = output[-1].get('type', '') if output else '' - if last_type == 'message': + if last_type == 'message' and output[-1].get('_tag_type') != content_type: # Use the output item's own text for tag detection item = output[-1] item_text = get_last_text(output) @@ -4679,14 +4669,14 @@ async def streaming_chat_response_handler(response, ctx): # Strip start and end tags from content start_tag_pattern = _start_tag_pattern(start_tag) - block_content = re.sub(start_tag_pattern, '', block_content).strip() + block_content = re.sub(start_tag_pattern, '', block_content) end_tag_pattern = rf'{re.escape(end_tag)}' end_tag_regex = re.compile(end_tag_pattern, re.DOTALL) split_content = end_tag_regex.split(block_content, maxsplit=1) block_content = split_content[0].strip() if split_content else '' - leftover_content = split_content[1].strip() if len(split_content) > 1 else '' + leftover_content = split_content[1].lstrip() if len(split_content) > 1 else '' if block_content: # Update the item with final content @@ -5113,8 +5103,8 @@ async def streaming_chat_response_handler(response, ctx): 'data': data, } ) - # Check for Responses API events (type field starts with "response.") - elif data.get('type', '').startswith('response.'): + # Check for Responses API events + elif data.get('type', '').startswith('response.') or data.get('type', '') == 'error': response_data_type = data.get('type', '') response_data_is_delta = response_data_type.endswith('.delta') output, response_metadata = handle_responses_streaming_event(data, output) @@ -5173,6 +5163,20 @@ async def streaming_chat_response_handler(response, ctx): response_metadata['usage'] = usage if response_metadata.get('error'): + log.error( + 'Provider returned error (streaming): %s', response_metadata['error'] + ) + if save_to_chat: + try: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': response_metadata['error']}, + }, + ) + except Exception: + pass await event_emitter( { 'type': 'chat:completion', diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ad959d3bc7..5c5dcce3a6 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -261,6 +261,26 @@ def get_output_text(output: list | None) -> str: return '\n'.join(texts) +def get_paired_tool_call_ids(messages: list[dict]) -> list[set[str]]: + """Tool call ids answered within each message's assistant-plus-tool-results block.""" + paired_ids_by_message = [set() for _ in messages] + for index, message in enumerate(messages): + if message.get('role') != 'assistant' or not message.get('tool_calls'): + continue + + block_end = index + 1 + while block_end < len(messages) and messages[block_end].get('role') == 'tool': + block_end += 1 + + requested_ids = {tool_call.get('id') for tool_call in message['tool_calls'] if tool_call.get('id')} + completed_ids = {tool_message.get('tool_call_id') for tool_message in messages[index + 1 : block_end]} + paired_ids = requested_ids & completed_ids + for block_index in range(index, block_end): + paired_ids_by_message[block_index] = paired_ids + + return paired_ids_by_message + + def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: """Drop unpaired tool_use / tool_result from a reconstructed conversation. @@ -271,22 +291,14 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: Well-formed output is unaffected: every id pairs, so nothing is stripped. """ - completed_tool_call_ids = { - message['tool_call_id'] for message in messages if message.get('role') == 'tool' and message.get('tool_call_id') - } - requested_tool_call_ids = { - tool_call['id'] - for message in messages - for tool_call in message.get('tool_calls') or () - if message.get('role') == 'assistant' and tool_call.get('id') - } + paired_ids_by_message = get_paired_tool_call_ids(messages) reconciled_messages = [] - for message in messages: + for message, paired_ids in zip(messages, paired_ids_by_message): role = message.get('role') - # Orphan tool result — no assistant ever claimed this call_id. - if role == 'tool' and message.get('tool_call_id') not in requested_tool_call_ids: + # Orphan tool result: not claimed by the assistant heading its tool block. + if role == 'tool' and message.get('tool_call_id') not in paired_ids: continue # Non-assistant or no tool_calls — pass through unchanged. @@ -294,10 +306,8 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: reconciled_messages.append(message) continue - # Keep only tool_calls whose id received a tool-role response. - valid_tool_calls = [ - tool_call for tool_call in message['tool_calls'] if tool_call.get('id') in completed_tool_call_ids - ] + # Keep only tool_calls whose id received an adjacent tool-role response. + valid_tool_calls = [tool_call for tool_call in message['tool_calls'] if tool_call.get('id') in paired_ids] if valid_tool_calls: reconciled_messages.append({**message, 'tool_calls': valid_tool_calls}) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 0bd933115b..bba1729a44 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -182,8 +182,18 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if model: if custom_model.is_active: + arena_meta = model['info']['meta'] if model.get('arena') else None model['name'] = custom_model.name model['info'] = custom_model.model_dump() + if arena_meta: + # Evaluation config owns arena access grants and model_ids + model['info']['meta'].update( + { + key: arena_meta[key] + for key in ('access_grants', 'model_ids', 'filter_mode') + if key in arena_meta + } + ) schema = get_chat_variables_schema(custom_model.params.model_dump().get('system')) if schema: model['info'].setdefault('meta', {})['chat_variables_schema'] = schema diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py index 8b6f090b4c..a35ad9b88f 100644 --- a/backend/open_webui/utils/notifications.py +++ b/backend/open_webui/utils/notifications.py @@ -268,9 +268,6 @@ def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], title = str(data.get('title') or event.message or 'Chat finished') content = str(data.get('message') or '') url = str(data.get('url') or '') - chat_id = str(data.get('chat_id') or '') - if chat_id and url.endswith(f'/c/{chat_id}'): - url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}' body = '\n'.join(part for part in (content, url) if part) return ( f'**{title}**', @@ -288,9 +285,6 @@ def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], title = str(event.message or 'Chat failed') content = str(data.get('message') or '') url = str(data.get('url') or '') - chat_id = str(data.get('chat_id') or '') - if chat_id and url.endswith(f'/c/{chat_id}'): - url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}' body = '\n'.join(part for part in (content, url) if part) return ( f'**{title}**', diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 061737a35b..281d352716 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1917,7 +1917,7 @@ class OAuthManager: detailed_error, exc_info=True, ) - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) except Exception as e: detailed_error = _build_oauth_callback_error_message(e) log.warning( @@ -1926,7 +1926,7 @@ class OAuthManager: detailed_error, exc_info=True, ) - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) # Try to get userinfo from the token first, some providers include it there user_data: UserInfo = token.get('userinfo') @@ -1949,7 +1949,7 @@ class OAuthManager: user_data = user_data['data'] if not user_data: log.warning('OAuth callback failed for provider %s, user data is missing', provider) - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) # Extract the "sub" claim, using custom claim if configured if auth_config.OAUTH_SUB_CLAIM: @@ -1959,7 +1959,7 @@ class OAuthManager: sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) if not sub: log.warning(f'OAuth callback failed, sub is missing: {user_data}') - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) sub = str(sub) oauth_data = {} @@ -1994,18 +1994,18 @@ class OAuthManager: email = primary_email else: log.warning('No primary email found in GitHub response') - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) else: log.warning('Failed to fetch GitHub email') - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) except Exception as e: log.warning(f'Error fetching GitHub email: {e}') - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) elif ENABLE_OAUTH_EMAIL_FALLBACK: email = f'{provider}@{sub}.local' else: log.warning(f'OAuth callback failed, email is missing: {user_data}') - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) email = email.lower() # If allowed domains are configured, check if the email domain is in the list @@ -2014,7 +2014,7 @@ class OAuthManager: 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) + raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED) # Check if the user exists user = await Users.get_user_by_oauth_sub(provider, sub, db=db) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 7ee2f9d3b3..94a9bc4769 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -379,10 +379,6 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: if 'tools' in openai_payload: ollama_payload['tools'] = openai_payload['tools'] - if 'max_tokens' in openai_payload: - ollama_payload['num_predict'] = openai_payload['max_tokens'] - del openai_payload['max_tokens'] - # If there are advanced parameters in the payload, format them in Ollama's options field if openai_payload.get('options'): # Copied before key deletions below so the caller's options stay intact @@ -430,6 +426,11 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: ollama_options['stop'] = openai_payload.get('stop') ollama_payload['options'] = ollama_options + if 'max_tokens' in openai_payload: + ollama_options = ollama_payload.get('options', {}) + ollama_options['num_predict'] = openai_payload['max_tokens'] + ollama_payload['options'] = ollama_options + if 'metadata' in openai_payload: ollama_payload['metadata'] = openai_payload['metadata'] diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py index 809dd601ae..f020e0cd94 100644 --- a/backend/open_webui/utils/subagents.py +++ b/backend/open_webui/utils/subagents.py @@ -14,7 +14,7 @@ from open_webui.models.chats import Chat, ChatForm, Chats from open_webui.models.config import Config from open_webui.models.users import UserModel, Users from open_webui.tasks import create_task, has_active_tasks -from open_webui.utils.auth import create_token +from open_webui.utils.auth import VERIFIED_USER_ROLES, create_token from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_message_list from sqlalchemy import select @@ -84,7 +84,7 @@ async def process_pending_internal_messages( return user = await Users.get_user_by_id(user_id) - if not user: + if not user or user.role not in VERIFIED_USER_ROLES: return async with get_async_db() as db: @@ -219,6 +219,7 @@ async def process_pending_internal_messages( history['messages'] = messages history['currentId'] = assistant_message_id chat.chat = {**(chat.chat or {}), 'history': history} + chat.current_message_id = assistant_message_id chat.updated_at = int(time.time()) await db.commit() diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py index d8be899dff..c2a91139ef 100644 --- a/backend/open_webui/utils/timers.py +++ b/backend/open_webui/utils/timers.py @@ -11,7 +11,7 @@ from datetime import datetime, timezone from typing import Literal from uuid import uuid4 -from fastapi import Request +from fastapi import HTTPException, Request from open_webui.internal.db import get_async_db from open_webui.models.chat_messages import ChatMessages from open_webui.models.chats import Chat, ChatForm, Chats @@ -351,6 +351,7 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> parent.chat = parent_chat history['currentId'] = assistant_message_id + parent.current_message_id = assistant_message_id parent.updated_at = int(time.time()) timer_row = await db.get(Chat, timer_id) if timer_row: @@ -416,7 +417,20 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) except Exception as exc: log.exception(f'Timer {timer_id} completion failed') - await _set_timer_state(timer_id, 'error', timer_error=str(exc)[:500]) + error_detail = exc.detail if isinstance(exc, HTTPException) else str(exc) + await _set_timer_state(timer_id, 'error', timer_error=error_detail[:500]) + await Chats.upsert_message_to_chat_by_id_and_message_id( + parent_chat_id, assistant_message_id, {'error': {'content': error_detail}, 'done': True} + ) + await sio.emit( + 'events', + { + 'chat_id': parent_chat_id, + 'message_id': assistant_message_id, + 'data': {'type': 'chat:message:error', 'data': {'error': {'content': error_detail}, 'done': True}}, + }, + room=f'user:{timer.user_id}', + ) async def _set_timer_state(timer_id: str, status: str, **fields) -> None: diff --git a/backend/open_webui/utils/tool_approval.py b/backend/open_webui/utils/tool_approval.py index c59b2d6316..7d5376bb01 100644 --- a/backend/open_webui/utils/tool_approval.py +++ b/backend/open_webui/utils/tool_approval.py @@ -5,7 +5,10 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from open_webui.constants import ERROR_MESSAGES +from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS from open_webui.models.chats import Chats +from open_webui.models.config import Config +from open_webui.models.users import Users from open_webui.socket.main import get_event_emitter from open_webui.utils.json_codec import JSONCodec @@ -25,7 +28,7 @@ async def resolve_tool_call_output( db: AsyncSession | None = None, ) -> dict: chat = await Chats.get_chat_by_id(chat_id, db=db) - if not chat or (chat.user_id != user.id and user.role != 'admin'): + if not chat or (chat.user_id != user.id and not (user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -160,8 +163,17 @@ async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call message model is missing.') messages = [] - if params.get('system'): - messages.append({'role': 'system', 'content': params.get('system')}) + system_prompt = params.get('system') + if not system_prompt: + # Mirror the chat UI's system prompt fallback + user = await Users.get_user_by_id(chat.user_id) + ui_settings = (user.settings.ui if user and user.settings else None) or {} + system_prompt = ui_settings.get('system') + if system_prompt is None: + default_interface_settings = await Config.get('ui.default_interface_settings') or {} + system_prompt = default_interface_settings.get('system') + if system_prompt: + messages.append({'role': 'system', 'content': system_prompt}) return { 'stream': params.get('stream_response', True), diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 7bd6a8dd69..120bbb39b4 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -101,7 +101,7 @@ from open_webui.tools.builtin import ( write_note, ) from open_webui.utils.access_control import has_access, has_connection_access, has_permission -from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.chat_id import is_saved_chat_id, is_temporary_chat_id from open_webui.utils.headers import ( bearer_auth_header, get_custom_headers, @@ -650,6 +650,7 @@ async def get_builtin_tools( and config.get('subagents.enable') and getattr(request.state, 'internal', False) is not True and getattr(request.state, 'direct', False) is not True + and not is_temporary_chat_id(metadata.get('chat_id')) ): builtin_functions.extend([delegate_task, timer]) diff --git a/backend/requirements-slim.txt b/backend/requirements-slim.txt index 16e20fd245..e5f83c4faf 100644 --- a/backend/requirements-slim.txt +++ b/backend/requirements-slim.txt @@ -21,7 +21,7 @@ joserfc==1.7.4 requests==2.34.2 regex==2026.5.9 google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches -aiohttp==3.13.5 # do not update to 3.13.3 - broken +aiohttp==3.14.3 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 aiofiles==25.1.0 @@ -36,7 +36,7 @@ aiosqlite==0.22.1 psycopg[binary]==3.3.4 alembic==1.18.4 -pycrdt==0.13.1 +pycrdt==0.14.8 redis==8.0.1 hiredis==3.4.2 @@ -67,7 +67,7 @@ lxml==6.1.1 validators==0.35.0 psutil==7.2.2 -pillow==12.2.0 +pillow==12.3.0 rank-bm25==0.2.2 black==26.5.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 1dc5e54b2b..d3050f6464 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,7 +16,7 @@ joserfc==1.7.4 requests==2.34.2 regex==2026.5.9 google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches -aiohttp==3.13.5 # do not update to 3.13.3 - broken +aiohttp==3.14.3 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 aiofiles==25.1.0 @@ -32,7 +32,7 @@ aiosqlite==0.22.1 psycopg[binary]==3.3.4 alembic==1.18.4 -pycrdt==0.13.1 +pycrdt==0.14.8 redis==8.0.1 hiredis==3.4.2 @@ -85,7 +85,7 @@ psutil==7.2.2 sentencepiece==0.2.1 soundfile==0.13.1 -pillow==12.2.0 +pillow==12.3.0 opencv-python-headless==4.13.0.92 rapidocr==3.9.2 rank-bm25==0.2.2 diff --git a/pyproject.toml b/pyproject.toml index 0d7e358047..776dc418ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "joserfc==1.7.4", "requests==2.34.2", - "aiohttp==3.13.5", # do not update to 3.13.3 - broken + "aiohttp==3.14.3", # do not update to 3.13.3 - broken "aiodns==3.6.1", # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER "aiocache==0.12.3", "aiofiles==25.1.0", @@ -38,7 +38,7 @@ dependencies = [ "psycopg[binary]==3.3.4", "alembic==1.18.4", - "pycrdt==0.13.1", + "pycrdt==0.14.8", "redis==8.0.1", "hiredis==3.4.2", # "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey @@ -93,7 +93,7 @@ dependencies = [ "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==12.2.0", + "pillow==12.3.0", "opencv-python-headless==4.13.0.92", "rapidocr==3.9.2", "rank-bm25==0.2.2", diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index a7edcf3913..0ada5f55c3 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -77,9 +77,29 @@ // remove trailing slash from url url = url.replace(/\/$/, ''); + let _headers = null; + + if (headers) { + try { + _headers = JSON.parse(headers); + if (typeof _headers !== 'object' || Array.isArray(_headers)) { + _headers = null; + throw new Error('Headers must be a valid JSON object'); + } + headers = JSON.stringify(_headers, null, 2); + } catch (error) { + toast.error($i18n.t('Headers must be a valid JSON object')); + return; + } + } + const res = await verifyOllamaConnection(localStorage.token, { url, - key + key, + config: { + auth_type, + ...(_headers ? { headers: _headers } : {}) + } }).catch((error) => { toast.error(`${error}`); }); @@ -241,6 +261,12 @@ showAdvanced = false; tags = []; modelIds = []; + headers = ''; + enable = true; + connectionType = 'external'; + provider = ''; + apiVersion = ''; + apiType = ''; }; const init = () => { diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 4baf3a01a5..aa0c109769 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -169,10 +169,7 @@ toast.error($i18n.t('External Document Loader URL required.')); return; } - if ( - RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external' && - RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS - ) { + if (RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS) { try { const headers = JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS); if (headers === null || typeof headers !== 'object' || Array.isArray(headers)) { diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 9ea9629a59..cb9fb1db4a 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -824,7 +824,7 @@ {/if} - +