diff --git a/README.md b/README.md index bc504ce641..1342c8d838 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it: -- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. +- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more. -- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes. - -- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more. +- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes. - 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync. diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 942841ea3d..b627c69a18 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -1193,6 +1193,20 @@ async def publish_model_provider_request_failed( else 'upstream_error' ) + # Server-log only; the upstream error body is otherwise invisible to admins + # (event sinks require an event function or webhook to be configured). + log.log( + logging.ERROR if status >= 500 else logging.WARNING, + 'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s', + provider, + status, + error_type, + base_url, + requested_model or '-', + error_code or '-', + error_text[:MAX_STRING_LENGTH] or '-', + ) + data = { 'error_type': error_type, 'status': status, diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 6e82c0b022..1900ee752c 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -147,7 +147,10 @@ async def get_function_models(request): return pipe_models -async def generate_function_chat_completion(request, form_data, user, models: dict = {}): +async def generate_function_chat_completion(request, form_data, user, models: dict | None = None): + if models is None: + models = {} + async def execute_pipe(pipe, params): if inspect.iscoroutinefunction(pipe): return await pipe(**params) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2f8542ec55..1b9ed9cc53 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1795,8 +1795,23 @@ async def generate_messages( # Counting must not turn a compatible generation request into an outage. log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True) + model_id = requested_model + model_info = await Models.get_model_by_id(model_id) + if model_info and model_info.base_model_id: + model_id = model_info.base_model_id + + passthrough_params = [] + models = request.app.state.OPENAI_MODELS + if not models or model_id not in models: + await openai.get_all_models(request, user=user) + models = request.app.state.OPENAI_MODELS + model = models.get(model_id) + if model: + _, _, api_config = await openai.get_openai_connection(model['urlIdx']) + passthrough_params = api_config.get('passthrough_params') or [] + # Convert Anthropic payload to OpenAI format - openai_payload = convert_anthropic_to_openai_payload(form_data) + openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params) # Route through the existing chat_completion handler response = await chat_completion(request, openai_payload, user) diff --git a/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py new file mode 100644 index 0000000000..403a51a5ef --- /dev/null +++ b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py @@ -0,0 +1,223 @@ +"""add current_message_id to chat + +Revision ID: 9a1b2c3d4e5f +Revises: 856c5b02fb54 +Create Date: 2026-07-23 00:00:00.000000 + +""" + +import json +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '9a1b2c3d4e5f' +down_revision: Union[str, None] = '856c5b02fb54' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +BATCH_SIZE = 150 + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('chat')] + if 'current_message_id' not in columns: + op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True)) + + chat = sa.table( + 'chat', + sa.column('id', sa.String()), + sa.column('chat', sa.Text()), + sa.column('current_message_id', sa.Text()), + ) + chat_message = sa.table( + 'chat_message', + sa.column('id', sa.Text()), + sa.column('chat_id', sa.Text()), + sa.column('parent_id', sa.Text()), + sa.column('created_at', sa.BigInteger()), + ) + + has_chat_message = 'chat_message' in inspector.get_table_names() + result = conn.execute( + sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options( + yield_per=BATCH_SIZE, + stream_results=True, + ) + ) + + while True: + rows = result.fetchmany(BATCH_SIZE) + if not rows: + break + + batch_chat_ids: list[str] = [] + candidates_by_chat: dict[str, list[str]] = {} + current_by_chat: dict[str, str | None] = {} + json_messages_by_chat: dict[str, dict[str, dict]] = {} + + for row in rows: + values = row._mapping + chat_id = values['id'] + prefix = f'{chat_id}-' + batch_chat_ids.append(chat_id) + current_by_chat[chat_id] = values['current_message_id'] + + chat_data = {} + if isinstance(values['chat'], dict): + chat_data = values['chat'] + elif isinstance(values['chat'], str): + try: + parsed = json.loads(values['chat']) + chat_data = parsed if isinstance(parsed, dict) else {} + except (TypeError, ValueError, json.JSONDecodeError): + pass + + history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {} + candidates_by_chat[chat_id] = [] + for candidate in ( + values['current_message_id'], + history.get('currentId'), + chat_data.get('currentId'), + chat_data.get('branchPointMessageId'), + ): + if not isinstance(candidate, str) or not candidate: + continue + candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate + if candidate not in candidates_by_chat[chat_id]: + candidates_by_chat[chat_id].append(candidate) + + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(chat_data.get('messages'), list): + messages = { + message['id']: message + for message in chat_data['messages'] + if isinstance(message, dict) and message.get('id') + } + if messages: + json_messages_by_chat[chat_id] = { + message_id: { + 'parent_id': message.get('parentId') if isinstance(message, dict) else None, + 'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0, + } + for message_id, message in messages.items() + } + + resolved: dict[str, str] = {} + + if has_chat_message: + candidate_ids = { + f'{chat_id}-{candidate}' + for chat_id, candidates in candidates_by_chat.items() + for candidate in candidates + } + if candidate_ids: + valid_by_chat: dict[str, set[str]] = {} + for row in conn.execute( + sa.select(chat_message.c.chat_id, chat_message.c.id).where( + chat_message.c.chat_id.in_(batch_chat_ids), + chat_message.c.id.in_(candidate_ids), + ) + ): + values = row._mapping + chat_id = values['chat_id'] + prefix = f'{chat_id}-' + message_id = values['id'] + if message_id and message_id.startswith(prefix): + message_id = message_id[len(prefix) :] + if message_id: + valid_by_chat.setdefault(chat_id, set()).add(message_id) + for chat_id, candidates in candidates_by_chat.items(): + valid_ids = valid_by_chat.get(chat_id, set()) + for candidate in candidates: + if candidate in valid_ids: + resolved[chat_id] = candidate + break + + unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved] + messages_by_chat: dict[str, dict[str, dict]] = {} + if unresolved_chat_ids: + for row in conn.execute( + sa.select( + chat_message.c.chat_id, + chat_message.c.id, + chat_message.c.parent_id, + chat_message.c.created_at, + ).where(chat_message.c.chat_id.in_(unresolved_chat_ids)) + ): + values = row._mapping + chat_id = values['chat_id'] + prefix = f'{chat_id}-' + message_id = values['id'] + if message_id and message_id.startswith(prefix): + message_id = message_id[len(prefix) :] + if not message_id: + continue + parent_id = values['parent_id'] + if parent_id and parent_id.startswith(prefix): + parent_id = parent_id[len(prefix) :] + messages_by_chat.setdefault(chat_id, {})[message_id] = { + 'parent_id': parent_id, + 'created_at': values['created_at'] or 0, + } + + for chat_id, messages in messages_by_chat.items(): + parent_ids = { + message['parent_id'] + for message in messages.values() + if message.get('parent_id') in messages + } + leaf_ids = [message_id for message_id in messages if message_id not in parent_ids] + resolved[chat_id] = max( + leaf_ids or list(messages), + key=lambda message_id: messages[message_id].get('created_at') or 0, + ) + + for chat_id in batch_chat_ids: + if chat_id in resolved: + continue + + messages = json_messages_by_chat.get(chat_id, {}) + valid_candidate = next( + (candidate for candidate in candidates_by_chat[chat_id] if candidate in messages), + None, + ) + if valid_candidate: + resolved[chat_id] = valid_candidate + elif messages: + parent_ids = { + message['parent_id'] + for message in messages.values() + if message.get('parent_id') in messages + } + leaf_ids = [message_id for message_id in messages if message_id not in parent_ids] + resolved[chat_id] = max( + leaf_ids or list(messages), + key=lambda message_id: messages[message_id].get('created_at') or 0, + ) + + updates = [ + {'chat_id': chat_id, 'current_message_id': message_id} + for chat_id, message_id in resolved.items() + if message_id and message_id != current_by_chat.get(chat_id) + ] + if updates: + conn.execute( + sa.update(chat) + .where(chat.c.id == sa.bindparam('update_chat_id')) + .values(current_message_id=sa.bindparam('update_current_message_id')), + [ + { + 'update_chat_id': row['chat_id'], + 'update_current_message_id': row['current_message_id'], + } + for row in updates + ], + ) + + +def downgrade() -> None: + op.drop_column('chat', 'current_message_id') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index c20c1db247..08aed47b22 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -62,6 +62,7 @@ class Chat(Base): # database table mapping for chat entity tasks = Column(JSON, nullable=True) summary = Column(Text, nullable=True) + current_message_id = Column(Text, nullable=True) last_read_at = Column(BigInteger, nullable=True) @@ -98,6 +99,7 @@ class ChatModel(BaseModel): tasks: list | None = None summary: str | None = None + current_message_id: str | None = None last_read_at: int | None = None @@ -145,6 +147,7 @@ class ChatForm(BaseModel): class ChatImportForm(ChatForm): meta: dict | None = {} pinned: bool | None = False + current_message_id: str | None = None created_at: int | None = None updated_at: int | None = None @@ -177,6 +180,7 @@ class ChatResponse(BaseModel): tasks: list | None = None summary: str | None = None + current_message_id: str | None = None context_usage: dict | None = None @@ -280,6 +284,21 @@ class ChatTable: """Recursively remove null bytes from strings in dict/list structures.""" return sanitize_data_for_db(obj) + def get_current_message_id(self, chat: dict | None) -> str | None: + chat = chat or {} + history = chat.get('history') if isinstance(chat.get('history'), dict) else {} + current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId') + if current_id: + return current_id + + messages = chat.get('messages') + if isinstance(messages, list): + for message in reversed(messages): + if isinstance(message, dict) and message.get('id'): + return message['id'] + + return None + def _sanitize_chat_row(self, chat_item): """ Clean a Chat SQLAlchemy model's title + chat JSON, @@ -375,6 +394,7 @@ class ChatTable: 'chat': self._clean_null_bytes(form_data.chat), 'folder_id': form_data.folder_id, 'meta': internal_meta or {}, + 'current_message_id': self.get_current_message_id(form_data.chat), 'created_at': int(time.time()), 'updated_at': int(time.time()), 'last_read_at': int(time.time()), @@ -388,8 +408,14 @@ class ChatTable: # Dual-write initial messages to chat_message table try: - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) + history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {} + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(form_data.chat.get('messages'), list): + messages = { + message.get('id'): message + for message in form_data.chat['messages'] + if isinstance(message, dict) and message.get('id') + } for message_id, message in messages.items(): if isinstance(message, dict) and message.get('role'): await ChatMessages.upsert_message( @@ -458,6 +484,7 @@ class ChatTable: 'meta': form_data.meta, 'pinned': form_data.pinned, 'folder_id': form_data.folder_id, + 'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat), 'created_at': (form_data.created_at if form_data.created_at else int(time.time())), 'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())), } @@ -497,8 +524,14 @@ class ChatTable: # Dual-write messages to chat_message table for form_data, chat_obj in zip(chat_import_forms, chats): - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) + history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {} + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(form_data.chat.get('messages'), list): + messages = { + message.get('id'): message + for message in form_data.chat['messages'] + if isinstance(message, dict) and message.get('id') + } for message_id, message in messages.items(): if isinstance(message, dict) and message.get('role'): try: @@ -530,6 +563,8 @@ class ChatTable: chat_item.chat = self._clean_null_bytes(chat) chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' + if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')): + chat_item.current_message_id = self.get_current_message_id(chat) if touch: chat_item.updated_at = int(time.time()) @@ -1627,9 +1662,23 @@ class ChatTable: postgres_content_sql = """ EXISTS ( SELECT 1 - FROM json_array_elements(Chat.chat->'messages') AS message - WHERE json_typeof(message->'content') = 'string' - AND LOWER(message->>'content') LIKE '%' || :content_key || '%' + FROM chat_message AS message + WHERE message.chat_id = Chat.id + AND message.user_id = Chat.user_id + AND json_typeof(message.content) = 'string' + AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_each(Chat.chat#>'{history,messages}') AS history_message + WHERE json_typeof(history_message.value->'content') = 'string' + AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_array_elements(Chat.chat->'messages') AS legacy_message + WHERE json_typeof(legacy_message->'content') = 'string' + AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%' ) """ @@ -1722,17 +1771,21 @@ class ChatTable: folder_id: str, skip: int = 0, limit: int = 60, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', db: AsyncSession | None = None, ) -> list[dict]: """Get chats in a folder across ALL users. Returns dicts with user_id.""" async with get_async_db_context(db) as session: + sort_column = Chat.title if sort_by == 'title' else Chat.updated_at + order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc() stmt = ( select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at) .filter_by(folder_id=folder_id) .filter(or_(Chat.pinned == False, Chat.pinned == None)) .filter_by(archived=False) .where(Chat.meta['internal'].as_boolean().is_not(True)) - .order_by(Chat.updated_at.desc(), Chat.id) + .order_by(order_clause, Chat.id) ) if skip: @@ -1754,6 +1807,22 @@ class ChatTable: for chat in all_chats ] + async def count_all_chats_by_folder_id( + self, + folder_id: str, + db: AsyncSession | None = None, + ) -> int: + async with get_async_db_context(db) as session: + stmt = ( + select(func.count(Chat.id)) + .filter_by(folder_id=folder_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .where(Chat.meta['internal'].as_boolean().is_not(True)) + ) + result = await session.execute(stmt) + return result.scalar_one() + async def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None ) -> list[ChatModel]: diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 93a55f690d..5d0b2266b7 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -48,6 +48,7 @@ API_CONFIG_FIELDS = ( 'azure', 'api_version', 'extra_params', + 'passthrough_params', ) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 9acd0c9b70..ff72dcfc65 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -3,7 +3,8 @@ from __future__ import annotations import json import logging import time -from typing import Optional +from copy import deepcopy +from typing import Any, Optional from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants @@ -22,6 +23,38 @@ log = logging.getLogger(__name__) _warned_profile_urls: set[str] = set() +def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any: + """Drop duplicated extracted text from ModelMeta.knowledge.""" + if not isinstance(knowledge, list): + return knowledge + + sanitized = [] + + for item in knowledge: + if not isinstance(item, dict): + sanitized.append(item) + continue + + next_item = item + data = item.get('data') + if isinstance(data, dict) and 'content' in data: + next_item = deepcopy(item) + next_item.get('data', {}).pop('content', None) + + file = next_item.get('file') + file_data = file.get('data') if isinstance(file, dict) else None + if isinstance(file_data, dict) and 'content' in file_data: + if next_item is item: + next_item = deepcopy(item) + file = next_item.get('file') + file_data = file.get('data') if isinstance(file, dict) else None + file_data.pop('content', None) + + sanitized.append(next_item) + + return sanitized + + # --- Models DB Schema --- @@ -37,6 +70,7 @@ class ModelMeta(BaseModel): profile_image_url: str | None = None description: str | None = Field(default=None, description='User-facing description of the model.') capabilities: dict | None = None + knowledge: list[Any] | None = None model_config = ConfigDict(extra='allow') @@ -56,6 +90,11 @@ class ModelMeta(BaseModel): ) return None + @field_validator('knowledge', mode='before') + @classmethod + def strip_knowledge_content(cls, v): + return strip_extracted_content_from_model_knowledge(v) + @model_validator(mode='before') @classmethod def normalize_tags(cls, data): @@ -152,6 +191,14 @@ class ModelsTable: access_grants: list[AccessGrantModel | None] = None, db: AsyncSession | None = None, ) -> ModelModel: + if isinstance(model.meta, dict): + knowledge = model.meta.get('knowledge') + stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge) + if stripped_knowledge != knowledge: + model.meta = {**model.meta, 'knowledge': stripped_knowledge} + if db is not None: + await db.commit() + model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'}) model_data['access_grants'] = ( access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 5a565407a6..8747c65a4f 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -38,6 +38,7 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.chat_fork import build_fork_history from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage from open_webui.utils.misc import get_message_list from open_webui.utils.models import get_all_models @@ -1167,7 +1168,8 @@ async def compact_chat_by_id( history = (chat.chat or {}).get('history') or {} messages_map = await Chats.get_messages_map_by_chat_id(id) - message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId')) + current_message_id = chat.current_message_id or history.get('currentId') + message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id) model_id = (form_data.model if form_data else None) or next( (message.get('model') for message in reversed(message_list) if message.get('model')), None, @@ -1564,6 +1566,96 @@ class CloneForm(BaseModel): title: str | None = None +class ForkForm(BaseModel): + message_id: str | None = None + + +@router.post('/{id}/fork', response_model=ChatResponse | None) +async def fork_chat_by_id( + request: Request, + id: str, + form_data: ForkForm | None = None, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await require_chat_import_permission(request, user, db) + + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + + if await has_active_tasks(request.app.state.redis, id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Wait for the current response to finish before forking.', + ) + + history = (chat.chat or {}).get('history') or {} + messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {} + if any( + message.get('role') == 'assistant' and message.get('done') is False + for message in messages_map.values() + if isinstance(message, dict) + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Wait for the current response to finish before forking.', + ) + + source_message_id = (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId') + if not source_message_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork') + + try: + fork_history, fork_messages = build_fork_history(messages_map, source_message_id) + except ValueError as exc: + detail = str(exc) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST, + detail=detail, + ) from exc + + updated_chat = {**(chat.chat or {})} + updated_chat.pop('currentId', None) + updated_chat.update( + { + 'originalChatId': chat.id, + 'branchPointMessageId': source_message_id, + 'title': f'{chat.title} (fork)', + 'history': fork_history, + 'messages': fork_messages, + } + ) + meta = { + **(chat.meta or {}), + 'forked_from': chat.id, + 'forked_from_message_id': source_message_id, + } + + fork = await Chats.insert_new_chat( + str(uuid4()), + user.id, + ChatForm(chat=updated_chat, folder_id=chat.folder_id), + db=db, + internal_meta=meta, + ) + + if not fork: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT()) + + if chat.pinned: + fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork + + await publish_event( + request, + EVENTS.CHAT_CLONED, + actor=user, + subject_id=fork.id, + data={'original_chat_id': id, 'forked_from_message_id': source_message_id}, + ) + return ChatResponse(**fork.model_dump()) + + @router.post('/{id}/clone', response_model=ChatResponse | None) async def clone_chat_by_id( request: Request, diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index a4ea4b8961..e3ebbfd96f 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -6,7 +6,7 @@ import uuid from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES @@ -482,6 +482,9 @@ async def update_folder_access_by_id( async def get_shared_folder_chats( request: Request, id: str, + page: int | None = Query(None, ge=1), + sort_by: str = Query('updated_at'), + sort_dir: str = Query('desc'), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -505,7 +508,17 @@ async def get_shared_folder_chats( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chats = await Chats.get_all_chats_by_folder_id(id, db=db) + limit = 10 + skip = (page - 1) * limit if page is not None else 0 + chats = await Chats.get_all_chats_by_folder_id( + id, + skip=skip, + limit=limit if page is not None else 60, + sort_by=sort_by, + sort_dir=sort_dir, + db=db, + ) + total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats) # Resolve owner names for display (avatar URLs are constructed client-side) owner_cache: dict[str, str] = {} @@ -516,10 +529,13 @@ async def get_shared_folder_chats( owner_cache[uid] = u.name if u else 'Unknown' chat['owner_name'] = owner_cache[uid] - return { + response = { 'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats], 'folder_permission': 'write' if has_write else 'read', } + if page is not None: + response.update({'total': total, 'has_more': skip + limit < total}) + return response ############################ diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 78247c0b7e..d26734da64 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -785,7 +785,11 @@ async def image_generations( images.append({'url': url}) return images elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': - if form_data.model: + # Automatic1111 holds one checkpoint instance-wide, so set_image_model + # persists the global default and switches the shared backend. Only an + # admin may do that; a non-admin generates on the currently configured + # checkpoint. The model field is not a per-user selection on this backend. + if form_data.model and user.role == 'admin': await set_image_model(request, form_data.model) data = { diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 60d95f176b..27a029c75d 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1180,6 +1180,14 @@ class OpenAICompletionForm(BaseModel): model_config = ConfigDict(extra='allow') +class OpenAIEmbeddingsForm(BaseModel): + """Payload for the OpenAI-compatible /v1/embeddings proxy.""" + + model: str + input: object + model_config = ConfigDict(extra='allow') + + @router.post('/v1/completions') @router.post('/v1/completions/{url_idx}') async def generate_openai_completion( @@ -1235,6 +1243,56 @@ async def generate_openai_completion( ) +@router.post('/v1/embeddings') +@router.post('/v1/embeddings/{url_idx}') +async def generate_openai_embeddings( + request: Request, + form_data: dict, + url_idx: int | None = None, + user=Depends(get_verified_user), # noqa: B008 +): + """Forward an embeddings request via the OpenAI-compatible proxy.""" + if not await Config.get('ollama.enable'): + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + + metadata = form_data.pop('metadata', None) + + try: + form_data = OpenAIEmbeddingsForm(**form_data) + except Exception as exc: + log.exception(exc) + raise HTTPException(status_code=400, detail=str(exc)) + + payload = {**form_data.model_dump(exclude_none=True)} + payload.pop('metadata', None) + + model_id = form_data.model + model_info = await Models.get_model_by_id(model_id) + if model_info is not None: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + await check_model_access(user, model_info) + else: + await check_model_access(user, None) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) + + prefix_id = api_config.get('prefix_id') + if prefix_id: + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + + return await send_request( + f'{url}/v1/embeddings', + payload=json.dumps(payload), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + user=user, + metadata=metadata, + api_config=api_config, + request=request, + ) + + @router.post('/v1/chat/completions') @router.post('/v1/chat/completions/{url_idx}') async def generate_openai_chat_completion( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index eaaa0092b2..cff598b236 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -200,6 +200,7 @@ class SharingPermissions(BaseModel): public_skills: bool = False notes: bool = False public_notes: bool = True + folders: bool = False public_chats: bool = False public_calendars: bool = False @@ -475,6 +476,23 @@ async def update_user_settings_by_session_user( # If the user is not an admin and does not have permission to use tool servers, remove the key updated_user_settings['ui'].pop('toolServers', None) + ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None + if ( + user.role != 'admin' + and ( + 'notifications' in updated_user_settings + or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications) + ) + and not await has_permission( + user.id, + 'features.webhooks', + await Config.get('user.permissions'), + ) + ): + updated_user_settings.pop('notifications', None) + if isinstance(ui_notifications, dict): + ui_notifications.pop('webhook_url', None) + user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: await publish_event( diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index cb01d62e00..25792b0e78 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -57,6 +57,29 @@ REDIS = None # Configure CORS for Socket.IO SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN +class LocalFilteredRedisManager(socketio.AsyncRedisManager): + """AsyncRedisManager that drops pub/sub emits with no local recipients. + + Every instance subscribed to the shared Socket.IO channel receives every + emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes + the full packet before discovering the target room has no participants on + this instance, so each instance burns CPU serializing payloads addressed + to sessions it does not host — a cost that grows with instance count. + Bail out before that work when the room is empty here. Only string + rooms take the fast path; broadcasts (``room=None``) and any other + room shape (e.g. lists, which upstream indexes before validating) + always pass through unchanged. + """ + + async def _handle_emit(self, message): + room = message.get('room') + if isinstance(room, str): + namespace = message.get('namespace') or '/' + if next(self.get_participants(namespace, room), None) is None: + return + await super()._handle_emit(message) + + if WEBSOCKET_MANAGER == 'redis': sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or '' ws_redis_url = ( @@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis': if sentinel_hosts else WEBSOCKET_REDIS_URL ) - redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS) + redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS) sio = socketio.AsyncServer( cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode='asgi', diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index 0ec1cc8983..44d092e792 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -686,12 +686,12 @@ async def _kb_grep( # Grep on piped input if piped_input is not None: - lines = piped_input.split('\\n') + lines = piped_input.split('\n') matched = [] for i, line in enumerate(lines, 1): if _matches(line): matched.append(f'{i}: {line}') - return '\\n'.join(matched) if matched else f'No matches for "{pattern}"' + return '\n'.join(matched) if matched else f'No matches for "{pattern}"' # Single file grep if file_ref and not dir_scope: @@ -702,7 +702,7 @@ async def _kb_grep( elif 'error' in resolved: return resolved['error'] else: - lines = resolved['content'].split('\\n') + lines = resolved['content'].split('\n') matched = [] for i, line in enumerate(lines, 1): if _matches(line): @@ -715,7 +715,7 @@ async def _kb_grep( if not matched: return f'No matches for "{pattern}" in {resolved["filename"]}' - return '\\n'.join(matched) + return '\n'.join(matched) # Cross-file grep (optionally scoped to directory) accessible = await _get_accessible_files(user, model_knowledge) diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py index 585a409b72..1249dd60cb 100644 --- a/backend/open_webui/utils/actions.py +++ b/backend/open_webui/utils/actions.py @@ -9,7 +9,7 @@ from open_webui.models.functions import Functions from open_webui.models.users import UserModel from open_webui.socket.main import get_event_call, get_event_emitter from open_webui.utils.middleware import process_tool_result -from open_webui.utils.models import get_all_models +from open_webui.utils.models import check_model_access, get_all_models from open_webui.utils.plugin import get_function_module_from_cache logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -46,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A raise Exception('Model not found') model = models[model_id] + # Availability gate — keep this route consistent with the actions a model + # actually surfaces to the client. Executing admin-authored Function code is + # intended; this only stops a disabled, unassigned, or access-restricted + # action from being reached by calling the route with a raw action_id. + if action.type != 'action' or not action.is_active: + raise Exception(f'Action not available: {action_id}') + + # Direct connections carry a client-supplied model the caller already owns, + # so scope the model-bound checks to server-resolved models. + if not getattr(request.state, 'direct', False) and user.role != 'admin': + await check_model_access(user, model) + # model['actions'] entries are '' or '.'; + # the function id is always the prefix. + surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])} + if action_id not in surfaced_action_ids: + raise Exception(f'Action not available: {action_id}') + __event_emitter__ = await get_event_emitter( { 'chat_id': data['chat_id'], diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 350c88921e..462d0173c4 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) +ANTHROPIC_CONVERTED_REQUEST_PARAMS = { + 'model', + 'messages', + 'system', + 'max_tokens', + 'temperature', + 'top_p', + 'top_k', + 'stop_sequences', + 'stream', + 'metadata', + 'service_tier', + 'tools', + 'tool_choice', + 'reasoning_effort', +} + def is_anthropic_url(url: str) -> bool: """Check if the URL is an Anthropic API endpoint.""" @@ -154,7 +171,9 @@ def _finalize_openai_content(blocks: list) -> str | list: return blocks -def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: +def convert_anthropic_to_openai_payload( + anthropic_payload: dict, passthrough_params: list[str] | str | None = None +) -> dict: """ Convert an Anthropic Messages API request to OpenAI Chat Completions format. @@ -395,6 +414,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: if 'max_tokens' in anthropic_payload: openai_payload['max_tokens'] = anthropic_payload['max_tokens'] + captured_passthrough_params = { + param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS + } + if isinstance(passthrough_params, str): + passthrough_params = passthrough_params.split(',') + elif not isinstance(passthrough_params, (list, tuple, set)): + passthrough_params = [] + passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()} + if '*' in passthrough_param_names: + openai_payload.update(captured_passthrough_params) + else: + for param in passthrough_param_names: + if param in captured_passthrough_params: + openai_payload[param] = captured_passthrough_params[param] + + output_config = anthropic_payload.get('output_config') + if isinstance(output_config, dict): + if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload: + openai_payload['reasoning_effort'] = output_config['effort'] + + format_config = output_config.get('format') + if isinstance(format_config, dict): + format_type = format_config.get('type') + if format_type == 'json_schema': + json_schema = { + 'name': format_config.get('name', 'response_schema'), + 'schema': format_config.get('schema', {}), + } + if 'description' in format_config: + json_schema['description'] = format_config['description'] + if 'strict' in format_config: + json_schema['strict'] = format_config['strict'] + openai_payload['response_format'] = { + 'type': 'json_schema', + 'json_schema': json_schema, + } + elif format_type == 'json_object': + openai_payload['response_format'] = {'type': format_type} + + if 'reasoning_effort' in anthropic_payload: + openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort'] + # Common parameters for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'): if param in anthropic_payload: @@ -471,6 +532,32 @@ def convert_openai_to_anthropic_response( # Build content blocks content = [] + message_thinking = message.get('thinking') + thinking_blocks = message.get('thinking_blocks') or [] + if not thinking_blocks and isinstance(message_thinking, dict): + thinking_blocks = message_thinking.get('blocks') or [] + + has_thinking = False + for block in thinking_blocks: + if not isinstance(block, dict): + continue + + thinking = block.get('thinking') or block.get('content') or block.get('text') + if not thinking: + continue + + thinking_block = {'type': 'thinking', 'thinking': thinking} + if block.get('signature'): + thinking_block['signature'] = block['signature'] + content.append(thinking_block) + has_thinking = True + + reasoning_content = message.get('reasoning_content') or message.get('reasoning') + if not reasoning_content and isinstance(message_thinking, str): + reasoning_content = message_thinking + if reasoning_content and not has_thinking: + content.append({'type': 'thinking', 'thinking': reasoning_content}) + message_content = message.get('content') if message_content: content.append({'type': 'text', 'text': message_content}) @@ -560,6 +647,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Track content blocks with a running index. # Each text block or tool_use block gets its own index. current_block_index = 0 + thinking_block_open = False text_block_open = False # Accumulated state for each tool call, keyed by tool call id. @@ -623,11 +711,52 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Update usage if present capture_usage(data.get('usage')) + reasoning_content = ( + delta.get('reasoning_content') + or delta.get('reasoning') + or delta.get('thinking') + or message.get('reasoning_content') + or message.get('reasoning') + ) + if not reasoning_content: + thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or [] + for block in thinking_blocks: + if isinstance(block, dict): + reasoning_content = block.get('thinking') or block.get('content') or block.get('text') + if reasoning_content: + break + + if reasoning_content and not text_block_open and not has_tool_calls: + if not thinking_block_open: + block_start = { + 'type': 'content_block_start', + 'index': current_block_index, + 'content_block': {'type': 'thinking', 'thinking': ''}, + } + yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + thinking_block_open = True + + block_delta = { + 'type': 'content_block_delta', + 'index': current_block_index, + 'delta': {'type': 'thinking_delta', 'thinking': reasoning_content}, + } + yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() + # --- Handle text content --- # Anthropic expects text blocks before tool blocks, so skip # text deltas once any tool call has started. content = delta.get('content') if content and not has_tool_calls: + if thinking_block_open: + block_stop = { + 'type': 'content_block_stop', + 'index': current_block_index, + } + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + thinking_block_open = False + current_block_index += 1 + if not text_block_open: block_start = { 'type': 'content_block_start', @@ -653,6 +782,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str if tool_calls: # Close text block if one is open (text comes before tools) + if thinking_block_open: + block_stop = { + 'type': 'content_block_stop', + 'index': current_block_index, + } + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + thinking_block_open = False + current_block_index += 1 + if text_block_open: block_stop = { 'type': 'content_block_stop', @@ -764,6 +902,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str except Exception as e: log.error(f'Error in Anthropic stream conversion: {e}') + # Close any open thinking block + if thinking_block_open: + block_stop = {'type': 'content_block_stop', 'index': current_block_index} + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + current_block_index += 1 + # Flush any tools that buffered arguments but never emitted a block for tool in tracked_tool_calls.values(): if not tool['started'] and tool['name']: diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 1ed539fefa..7189bfcad8 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials from open_webui.env import CUSTOM_API_KEY_HEADER from open_webui.internal.db import ScopedSession -from open_webui.models.config import Config from open_webui.utils.auth import get_http_authorization_cred from starlette.datastructures import MutableHeaders from starlette.requests import Request @@ -138,9 +137,7 @@ class AuthTokenMiddleware: the middleware checks that instead and avoids the 401 short-circuit. Routes that depend on `get_verified_user` etc. read this state. - Also exposes `request.state.enable_api_keys` (snapshotted at request - entry from runtime config) and stamps an `X-Process-Time` response - header. + Also stamps an `X-Process-Time` response header. """ def __init__(self, app: ASGIApp, *, fastapi_app) -> None: @@ -166,7 +163,6 @@ class AuthTokenMiddleware: token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) request.state.token = token - request.state.enable_api_keys = await Config.get('auth.enable_api_keys') async def send_with_timing(message: Message) -> None: if message['type'] == 'http.response.start': @@ -231,7 +227,15 @@ class RedirectMiddleware: return path = scope.get('path', '') - query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + raw_query = scope.get('query_string', b'') + # This middleware only acts on /watch?v= and ?shared= URLs; skip the + # decode + parse_qs work for every other GET. (A false positive on the + # substring check just falls through to the full parse below.) + if not (path.endswith('/watch') or b'shared' in raw_query): + await self.app(scope, receive, send) + return + + query_string = raw_query.decode('latin-1', errors='replace') query_params = parse_qs(query_string) redirect_params: dict[str, str] = {} diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index c8ff94bc09..7e5dc24a91 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -437,24 +437,24 @@ async def get_current_user_by_api_key(request, api_key: str): detail=ERROR_MESSAGES.INVALID_TOKEN, ) - user_permissions = await Config.get('user.permissions') - enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions') - allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '') + if not await Config.get('auth.enable_api_keys'): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) - if not request.state.enable_api_keys or ( - user.role != 'admin' - and not await has_permission( + if user.role != 'admin': + user_permissions = await Config.get('user.permissions') + if not await has_permission( user.id, 'features.api_keys', user_permissions, - ) - ): - raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) # Enforce endpoint restrictions — checked here (not in middleware) # so it applies regardless of how the API key was transported # (Authorization header, cookie, x-api-key header, etc.). + enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions') if enable_endpoint_restrictions: + allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '') allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()] request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710) is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths) diff --git a/backend/open_webui/utils/chat_fork.py b/backend/open_webui/utils/chat_fork.py new file mode 100644 index 0000000000..4963af0609 --- /dev/null +++ b/backend/open_webui/utils/chat_fork.py @@ -0,0 +1,41 @@ +from copy import deepcopy + + +def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]: + if not messages_map: + raise ValueError('chat has no messages to fork') + + branch: list[tuple[str, dict]] = [] + seen: set[str] = set() + message_id = source_message_id + + while message_id: + if message_id in seen: + raise ValueError('message branch contains a cycle') + seen.add(message_id) + + message = messages_map.get(message_id) + if not isinstance(message, dict): + raise ValueError('message not found') + + branch.append((message_id, message)) + message_id = message.get('parentId') + + fork_messages: dict[str, dict] = {} + ordered_messages: list[dict] = [] + parent_id = None + + for message_id, message in reversed(branch): + copied = deepcopy(message) + copied['id'] = message_id + copied['parentId'] = parent_id + copied['childrenIds'] = [] + + if parent_id: + fork_messages[parent_id]['childrenIds'] = [message_id] + + fork_messages[message_id] = copied + ordered_messages.append(copied) + parent_id = message_id + + return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index ca0e7a4f9d..910fa5b7b6 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -148,8 +148,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d if not config['enable']: return {'ok': True, 'compacted': False, 'reason': 'disabled'} - history = (chat.chat or {}).get('history') or {} - current_id = history.get('currentId') + chat_data = chat.chat or {} + history = chat_data.get('history') or {} + current_id = getattr(chat, 'current_message_id', None) or history.get('currentId') + if not current_id: + current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId') + if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']: + current_id = chat_data['messages'][-1].get('id') if not current_id: return {'ok': True, 'compacted': False, 'reason': 'empty'} @@ -216,8 +221,13 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None: - history = (chat.chat or {}).get('history') or {} - current_id = history.get('currentId') + chat_data = chat.chat or {} + history = chat_data.get('history') or {} + current_id = getattr(chat, 'current_message_id', None) or history.get('currentId') + if not current_id: + current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId') + if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']: + current_id = chat_data['messages'][-1].get('id') if not current_id: return None diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a1a2a432f5..db86b0c8be 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -4490,7 +4490,12 @@ async def streaming_chat_response_handler(response, ctx): user, ) - content = f'{content}{value}' + if isinstance(value, str): + # In-place append — avoids copying the full + # accumulated response on every chunk. + content += value + else: + content = f'{content}{value}' # Check if we're inside a tag-based block # (reasoning, code_interpreter, or solution). @@ -4597,16 +4602,17 @@ async def streaming_chat_response_handler(response, ctx): if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith( 'channel:' ): + current_output = full_output() # Save message in the database await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { - 'output': full_output(), + 'output': current_output, }, ) data = { - 'output': full_output(), + 'output': current_output, } delta_type = 'content' else: diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 4012685965..9421975dcf 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -68,6 +68,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'models.base_models_cache', 'evaluation.arena.enable', 'evaluation.arena.models', + 'models.default_metadata', ) if ( request.app.state.MODELS @@ -125,23 +126,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ] models = models + arena_models - global_action_ids = ( - {function.id for function in await Functions.get_global_action_functions()} if ENABLE_PLUGINS else set() - ) - enabled_action_ids = ( - {function.id for function in await Functions.get_functions_by_type('action', active_only=True)} - if ENABLE_PLUGINS - else set() - ) + # One query per type: the global sets are subsets of the active sets, so + # deriving them from the same rows halves the function-table queries. + if ENABLE_PLUGINS: + active_actions = await Functions.get_functions_by_type('action', active_only=True) + global_action_ids = {function.id for function in active_actions if function.is_global} + enabled_action_ids = {function.id for function in active_actions} - global_filter_ids = ( - {function.id for function in await Functions.get_global_filter_functions()} if ENABLE_PLUGINS else set() - ) - enabled_filter_ids = ( - {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)} - if ENABLE_PLUGINS - else set() - ) + active_filters = await Functions.get_functions_by_type('filter', active_only=True) + global_filter_ids = {function.id for function in active_filters if function.is_global} + enabled_filter_ids = {function.id for function in active_filters} + else: + global_action_ids = set() + enabled_action_ids = set() + global_filter_ids = set() + enabled_filter_ids = set() custom_models = await Models.get_all_models() @@ -305,7 +304,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) # Apply global model defaults to all models # Per-model overrides take precedence over global defaults - default_metadata = await Config.get('models.default_metadata', {}) or {} + default_metadata = config.get('models.default_metadata') or {} if default_metadata: for model in models: diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index 090abeea33..c6cbbce2ba 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -15,16 +15,19 @@ class SecurityHeadersMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app + # Headers derive only from env vars, which are static for the process + # lifetime — compute them once instead of per response. + self._headers = list(set_security_headers().items()) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope['type'] != 'http': + if scope['type'] != 'http' or not self._headers: await self.app(scope, receive, send) return async def send_with_security_headers(message: Message) -> None: if message['type'] == 'http.response.start': headers = MutableHeaders(scope=message) - for key, value in set_security_headers().items(): + for key, value in self._headers: headers[key] = value await send(message) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index d5646443e6..7ccc3f7261 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -33,6 +33,7 @@ alembic==1.18.4 pycrdt==0.13.1 redis +hiredis APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/backend/requirements.txt b/backend/requirements.txt index e33bf131ea..4a3169762f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,7 +30,8 @@ psycopg[binary]==3.3.4 alembic==1.18.4 pycrdt==0.13.1 -redis==8.0.0 +redis==8.0.1 +hiredis==3.4.0 APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/pyproject.toml b/pyproject.toml index 468543e71c..1944904e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ dependencies = [ "alembic==1.18.4", "pycrdt==0.13.1", - "redis==8.0.0", + "redis==8.0.1", + "hiredis==3.4.0", # "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey "pytz==2026.2", @@ -151,7 +152,6 @@ all = [ "psycopg2-binary==2.9.12", "pgvector==0.4.2", "moto[s3]>=5.0.26", - "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.2.5", diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index b4bc2e002c..64d2960dbf 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -920,6 +920,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) = return res; }; +export const forkChatById = async (token: string, id: string, messageId?: string | null) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + message_id: messageId ?? null + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const cloneSharedChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index b79e588947..d82f591289 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => { return res; }; -export const getSharedFolderChats = async (token: string, folderId: string) => { +export const getSharedFolderChats = async ( + token: string, + folderId: string, + params: { + page?: number | null; + sortBy?: 'title' | 'updated_at'; + sortDir?: 'asc' | 'desc'; + } = {} +) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` + const searchParams = new URLSearchParams(); + if (params.page !== undefined && params.page !== null) { + searchParams.append('page', `${params.page}`); + } + if (params.sortBy) { + searchParams.append('sort_by', params.sortBy); + } + if (params.sortDir) { + searchParams.append('sort_dir', params.sortDir); + } + const query = searchParams.toString(); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } } - }) + ) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 8df0436283..6715c80b1a 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -49,6 +49,7 @@ let apiType = ''; // '' = chat completions (default), 'responses' = Responses API let headers = ''; + let passthroughParams = ''; let tags = []; @@ -57,12 +58,19 @@ let loading = false; let showDeleteConfirmDialog = false; + let showAdvanced = false; const inputClass = 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; const selectClass = 'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; + const parsePassthroughParams = (value: string) => + value + .split(',') + .map((param) => param.trim()) + .filter(Boolean); + const verifyOllamaHandler = async () => { // remove trailing slash from url url = url.replace(/\/$/, ''); @@ -109,6 +117,7 @@ ...(provider ? { provider } : {}), ...(azure ? { azure: true } : {}), api_version: apiVersion, + passthrough_params: parsePassthroughParams(passthroughParams), ...(_headers ? { headers: _headers } : {}) } }, @@ -149,6 +158,7 @@ if (azure) { if (!apiVersion) { loading = false; + showAdvanced = true; toast.error($i18n.t('API Version is required')); return; @@ -163,6 +173,7 @@ if (modelIds.length === 0) { loading = false; + showAdvanced = true; toast.error($i18n.t('Deployment names are required for Azure OpenAI')); return; } @@ -195,6 +206,7 @@ connection_type: connectionType, auth_type, headers: headers ? JSON.parse(headers) : undefined, + passthrough_params: parsePassthroughParams(passthroughParams), ...(provider ? { provider } : {}), ...(!ollama && azure ? { azure: true } : {}), ...(azure ? { api_version: apiVersion } : {}), @@ -211,6 +223,8 @@ key = ''; auth_type = 'bearer'; prefixId = ''; + passthroughParams = ''; + showAdvanced = false; tags = []; modelIds = []; }; @@ -228,6 +242,9 @@ enable = connection.config?.enable ?? true; tags = connection.config?.tags ?? []; prefixId = connection.config?.prefix_id ?? ''; + passthroughParams = Array.isArray(connection.config?.passthrough_params) + ? connection.config.passthrough_params.join(', ') + : (connection.config?.passthrough_params ?? ''); modelIds = connection.config?.model_ids ?? []; if (ollama) { @@ -429,107 +446,6 @@ - {#if !direct} -
-
- - -
- -