diff --git a/.github/workflows/issue-label.yaml b/.github/workflows/issue-label.yaml new file mode 100644 index 0000000000..94f0d82ead --- /dev/null +++ b/.github/workflows/issue-label.yaml @@ -0,0 +1,43 @@ +name: Issue Labeler + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + label-bug-reports: + runs-on: ubuntu-latest + steps: + - name: Add "bug" label to unlabeled bug reports + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + + // Web-form submissions already carry the label from the issue template + if (issue.labels.some((label) => label.name === 'bug')) { + return; + } + + const title = issue.title ?? ''; + const body = issue.body ?? ''; + + // Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..." + const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title); + + // API/CLI-created issues that reproduce the bug report form structure. + // Only headings distinctive to the bug form (both are required fields there) — + // generic headings like "Expected Behavior" also appear in freeform feature requests. + const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body); + + if (bugLikeTitle || bugFormBody) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['bug'] + }); + } diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index fceedd0a87..6dfec3c42e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2143,6 +2143,10 @@ CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THR _CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP') CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None +CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min( + 50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40'))) +) + CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '') TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') @@ -2567,6 +2571,24 @@ if _oauth_authorize_params: log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') +def oauth_client_kwargs(scope: str, **kwargs): + client_kwargs = { + 'scope': scope, + **kwargs, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), + } + + if OAUTH_CODE_CHALLENGE_METHOD == 'S256': + client_kwargs['code_challenge_method'] = 'S256' + elif OAUTH_CODE_CHALLENGE_METHOD: + raise Exception( + 'Code challenge methods other than "%s" not supported. Given: "%s"' + % ('S256', OAUTH_CODE_CHALLENGE_METHOD) + ) + + return client_kwargs + + def load_oauth_providers(): OAUTH_PROVIDERS.clear() if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: @@ -2577,10 +2599,7 @@ def load_oauth_providers(): client_id=GOOGLE_CLIENT_ID, client_secret=GOOGLE_CLIENT_SECRET, server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', - client_kwargs={ - 'scope': GOOGLE_OAUTH_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE), redirect_uri=GOOGLE_REDIRECT_URI, **({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) @@ -2598,10 +2617,7 @@ def load_oauth_providers(): client_id=MICROSOFT_CLIENT_ID, client_secret=MICROSOFT_CLIENT_SECRET, server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}', - client_kwargs={ - 'scope': MICROSOFT_OAUTH_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE), redirect_uri=MICROSOFT_REDIRECT_URI, ) return client @@ -2622,10 +2638,7 @@ def load_oauth_providers(): authorize_url='https://github.com/login/oauth/authorize', api_base_url='https://api.github.com', userinfo_endpoint='https://api.github.com/user', - client_kwargs={ - 'scope': GITHUB_CLIENT_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE), redirect_uri=GITHUB_CLIENT_REDIRECT_URI, ) return client @@ -2638,30 +2651,19 @@ def load_oauth_providers(): if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL: def oidc_oauth_register(oauth: OAuth): - client_kwargs = { - 'scope': OAUTH_SCOPES, - **( - {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD} - if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD - else {} - ), - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - } - - if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256': - client_kwargs['code_challenge_method'] = 'S256' - elif OAUTH_CODE_CHALLENGE_METHOD: - raise Exception( - 'Code challenge methods other than "%s" not supported. Given: "%s"' - % ('S256', OAUTH_CODE_CHALLENGE_METHOD) - ) - client = oauth.register( name='oidc', client_id=OAUTH_CLIENT_ID, client_secret=OAUTH_CLIENT_SECRET, server_metadata_url=OPENID_PROVIDER_URL, - client_kwargs=client_kwargs, + client_kwargs=oauth_client_kwargs( + OAUTH_SCOPES, + **( + {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD} + if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD + else {} + ), + ), redirect_uri=OPENID_REDIRECT_URI, ) return client @@ -3056,6 +3058,7 @@ DEFAULT_CONFIG = { 'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION, 'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD, 'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP, + 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index da99fbb94d..6c41313499 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -594,6 +594,15 @@ try: except (ValueError, TypeError): AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10 +AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024)) +try: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE) +except Exception: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024 + +if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024 + # SSL verification for tool server connections specifically. # Accepts "True", "False", or a path to a CA bundle file. @@ -1093,15 +1102,19 @@ except ValueError: MAX_BODY_LOG_SIZE = 2048 # Comma separated list for urls to exclude from audit -AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',') -AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] -AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS] +AUDIT_EXCLUDED_PATHS = [ + path + for path in ( + path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',') + ) + if path +] # Comma separated list of urls to include in audit (whitelist mode) # When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored -AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',') -AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS] -AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path] +AUDIT_INCLUDED_PATHS = [ + path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path +] # When enabled, GET requests are also audited (disabled by default to avoid log noise) ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true' diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index 7c890e0ca2..acce09ed6d 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -370,6 +370,8 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL): if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: # Generous default — async coroutines + no session sharing = high connection demand. + # No pool_pre_ping: a local SQLite file cannot drop connections, and the + # ping costs a worker-thread hop plus a SELECT 1 on every checkout. _sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512 async_engine = create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, @@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: pool_size=_sqlite_pool_size, pool_timeout=DATABASE_POOL_TIMEOUT, pool_recycle=DATABASE_POOL_RECYCLE, - pool_pre_ping=True, ) @event.listens_for(async_engine.sync_engine, 'connect') diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 1b9ed9cc53..15412572b0 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -220,6 +220,9 @@ from open_webui.utils.chat import ( from open_webui.utils.chat import ( generate_chat_completion as chat_completion_handler, ) +from open_webui.utils.chat_variables import ( + normalize_chat_variables, +) from open_webui.utils.embeddings import generate_embeddings from open_webui.utils.logger import start_logger from open_webui.utils.middleware import ( @@ -717,6 +720,25 @@ app.state.speech_speaker_embeddings_dataset = None app.state.MODELS = MODELS # Add the middleware to the app +try: + audit_level = AuditLevel(AUDIT_LOG_LEVEL) +except ValueError as e: + logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}') + audit_level = AuditLevel.NONE + +# Added before CompressMiddleware so audit sits inside compression and +# captures response bodies before they are compressed (last added runs +# outermost). +if audit_level != AuditLevel.NONE: + app.add_middleware( + AuditLoggingMiddleware, + audit_level=audit_level, + excluded_paths=AUDIT_EXCLUDED_PATHS, + included_paths=AUDIT_INCLUDED_PATHS, + audit_get_requests=ENABLE_AUDIT_GET_REQUESTS, + max_body_size=MAX_BODY_LOG_SIZE, + ) + if ENABLE_COMPRESSION_MIDDLEWARE: app.add_middleware(CompressMiddleware) @@ -795,21 +817,6 @@ if ENABLE_SCIM: app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim']) -try: - audit_level = AuditLevel(AUDIT_LOG_LEVEL) -except ValueError as e: - logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}') - audit_level = AuditLevel.NONE - -if audit_level != AuditLevel.NONE: - app.add_middleware( - AuditLoggingMiddleware, - audit_level=audit_level, - excluded_paths=AUDIT_EXCLUDED_PATHS, - included_paths=AUDIT_INCLUDED_PATHS, - audit_get_requests=ENABLE_AUDIT_GET_REQUESTS, - max_body_size=MAX_BODY_LOG_SIZE, - ) ################################## # # Chat Endpoints @@ -822,12 +829,18 @@ if audit_level != AuditLevel.NONE: async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)): all_models = await get_all_models(request, refresh=refresh, user=user) - models = [] - for model in all_models: - # Filter out filter pipelines - if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter': - continue + # Filter out filter pipelines + models = [model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')] + # Chat requests resolve models by ID from request.app.state.MODELS, where + # duplicate IDs collapse to the last model. Return the same effective list. + models = list({model['id']: model for model in models}.values()) + + # Access-filter first so the per-model payload work below only runs for + # models the caller can actually see. + models = await get_filtered_models(models, user) + + for model in models: # Remove profile image URL to reduce payload size if model.get('info', {}).get('meta', {}).get('profile_image_url'): model['info']['meta'].pop('profile_image_url', None) @@ -841,13 +854,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v except Exception as e: log.debug(f'Error processing model tags: {e}') model['tags'] = [] - pass - - models.append(model) - - # Chat requests resolve models by ID from request.app.state.MODELS, where - # duplicate IDs collapse to the last model. Return the same effective list. - models = list({model['id']: model for model in models}.values()) model_order_list = await Config.get('ui.model_order_list') if model_order_list: @@ -860,11 +866,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v ) ) - models = await get_filtered_models(models, user) - - log.debug( - f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' - ) + if log.isEnabledFor(logging.DEBUG): + log.debug( + f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' + ) return {'data': models} @@ -1133,6 +1138,13 @@ async def chat_completion( message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}] user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) + chat_id = form_data.get('chat_id') or '' + chat_variables = form_data.pop('chat_variables', None) + if chat_variables is None: + existing_chat = await Chats.get_chat_by_id(chat_id) if chat_id else None + chat_variables = existing_chat.variables if existing_chat else {} + + chat_variables = normalize_chat_variables(chat_variables) # Drop tool_servers if caller lacks features.direct_tool_servers — # mirrors the storage-side strip in user/settings/update. @@ -1164,6 +1176,7 @@ async def chat_completion( 'files': form_data.get('files', None), 'features': form_data.get('features', {}), 'variables': form_data.get('variables', {}), + 'chat_variables': chat_variables, 'model': model, 'direct': model_item.get('direct', False), 'params': { @@ -1278,6 +1291,7 @@ async def chat_completion( 'tags': [], 'timestamp': int(time.time() * 1000), }, + variables=chat_variables, folder_id=metadata.get('folder_id'), ), ) @@ -1383,6 +1397,8 @@ async def chat_completion( updated['models'] = selected_chat_models await Chats.update_chat_by_id(chat_id, updated) + await Chats.update_chat_variables_by_id(chat_id, chat_variables) + # Save user message to DB if user_message and user_message.get('id'): await Chats.upsert_message_to_chat_by_id_and_message_id( diff --git a/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py b/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py new file mode 100644 index 0000000000..d1de5c05b8 --- /dev/null +++ b/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py @@ -0,0 +1,36 @@ +"""Add memory (id, user_id) covering index + +Revision ID: 55f1302ac17c +Revises: b0018471bbbe +Create Date: 2026-07-24 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = '55f1302ac17c' +down_revision: Union[str, None] = 'b0018471bbbe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + indexes = {index['name'] for index in inspector.get_indexes('memory')} + + if 'ix_memory_id_user_id' not in indexes: + op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id']) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + indexes = {index['name'] for index in inspector.get_indexes('memory')} + + if 'ix_memory_id_user_id' in indexes: + op.drop_index('ix_memory_id_user_id', table_name='memory') diff --git a/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py new file mode 100644 index 0000000000..73d3687c1f --- /dev/null +++ b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py @@ -0,0 +1,31 @@ +"""add user variables + +Revision ID: b0018471bbbe +Revises: c49178636c78 +Create Date: 2026-07-24 01:21:46.457057 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b0018471bbbe' +down_revision: Union[str, None] = 'c49178636c78' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('user')] + + if 'variables' not in columns: + op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('user', 'variables') diff --git a/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py b/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py new file mode 100644 index 0000000000..7823f3867c --- /dev/null +++ b/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py @@ -0,0 +1,31 @@ +"""add chat variables + +Revision ID: c49178636c78 +Revises: 9a1b2c3d4e5f +Create Date: 2026-07-23 23:33:45.497453 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'c49178636c78' +down_revision: Union[str, None] = '9a1b2c3d4e5f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('chat')] + + if 'variables' not in columns: + op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('chat', 'variables') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 1c86dc08e7..f944ff8655 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -316,7 +316,6 @@ class AccessGrantsTable: ) db.add(grant) await db.commit() - await db.refresh(grant) return AccessGrantModel.model_validate(grant) async def revoke_access( diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 6538c1dbf9..d852b4ac40 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -133,9 +133,8 @@ class AuthsTable: oauth=oauth, db=session, ) - # persist both records and reload generated defaults + # persist both records await session.commit() - await session.refresh(credential) return created_user if credential and created_user else None async def authenticate_user( diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 4038a3bdbe..fbd74ccf5e 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -139,7 +139,6 @@ class AutomationTable: ) db.add(row) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int: @@ -224,7 +223,6 @@ class AutomationTable: row.next_run_at = next_run_at row.updated_at = int(time.time_ns()) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) async def toggle( @@ -241,7 +239,6 @@ class AutomationTable: row.next_run_at = next_run_at if row.is_active else None row.updated_at = int(time.time_ns()) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool: @@ -324,7 +321,6 @@ class AutomationRunTable: ) db.add(row) await db.commit() - await db.refresh(row) return AutomationRunModel.model_validate(row) async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]: diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 2067ccfab5..b9be370f31 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -500,9 +500,12 @@ class CalendarEventTable: # Filter to requested calendars only accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids] - # Also get event IDs where user is an attendee + # Also get event IDs where the user is an attendee, excluding invites they declined attendee_event_ids_result = await db.execute( - select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id, + CalendarEventAttendee.status != 'declined', + ) ) attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] @@ -764,22 +767,34 @@ class CalendarEventAttendeeTable: async def set_attendees( self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None ) -> list[CalendarEventAttendeeModel]: - """Replace all attendees for an event. + """Replace all attendees for an event ({user_id, meta?} per dict). - Each dict in attendees: {user_id: str, status?: str, meta?: dict} + RSVP status is the attendee's alone to set (via update_rsvp): an existing + attendee keeps their status, a newly added one starts 'pending'. A + caller-supplied status is ignored so an organiser cannot set it for others. """ async with get_async_db_context(db) as db: + existing_status = { + row.user_id: row.status + for row in ( + await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id) + ) + ).scalars() + } + # Remove existing await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) now = int(time.time_ns()) models = [] for att in attendees: + user_id = att['user_id'] row = CalendarEventAttendee( id=str(uuid4()), event_id=event_id, - user_id=att['user_id'], - status=att.get('status', 'pending'), + user_id=user_id, + status=existing_status.get(user_id, 'pending'), meta=att.get('meta'), created_at=now, updated_at=now, diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 9d5f130355..5028cdf2c8 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -869,7 +869,6 @@ class ChannelTable: result = ChannelFile(**channel_file.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return ChannelFileModel.model_validate(result) else: diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 048be9b270..8215cb69db 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -259,7 +259,6 @@ class ChatMessageTable: existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage) existing.updated_at = now await db.commit() - await db.refresh(existing) return ChatMessageModel.model_validate(existing) else: # Insert new @@ -288,7 +287,6 @@ class ChatMessageTable: ) db.add(message) await db.commit() - await db.refresh(message) return ChatMessageModel.model_validate(message) async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]: diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 08aed47b22..b2e252f588 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -13,8 +13,8 @@ from open_webui.models.automations import AutomationRun from open_webui.models.chat_messages import ChatMessage, ChatMessages from open_webui.models.folders import Folders from open_webui.models.tags import Tag, TagModel, Tags -from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db -from pydantic import BaseModel, ConfigDict +from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db +from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import ( JSON, BigInteger, @@ -58,6 +58,7 @@ class Chat(Base): # database table mapping for chat entity pinned = Column(Boolean, default=False, nullable=True) meta = Column(JSON, server_default='{}') + variables = Column(JSON, nullable=True) folder_id = Column(Text, nullable=True) tasks = Column(JSON, nullable=True) @@ -95,6 +96,7 @@ class ChatModel(BaseModel): pinned: bool | None = False meta: dict = {} + variables: dict = {} folder_id: str | None = None tasks: list | None = None @@ -103,6 +105,11 @@ class ChatModel(BaseModel): last_read_at: int | None = None + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class ChatFile(Base): __tablename__ = 'chat_file' @@ -141,6 +148,7 @@ class ChatFileModel(BaseModel): class ChatForm(BaseModel): chat: dict + variables: dict | None = None folder_id: str | None = None @@ -176,6 +184,7 @@ class ChatResponse(BaseModel): archived: bool pinned: bool | None = False meta: dict = {} + variables: dict = {} folder_id: str | None = None tasks: list | None = None @@ -183,6 +192,11 @@ class ChatResponse(BaseModel): current_message_id: str | None = None context_usage: dict | None = None + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class ChatTitleIdResponse(BaseModel): id: str @@ -394,6 +408,7 @@ class ChatTable: 'chat': self._clean_null_bytes(form_data.chat), 'folder_id': form_data.folder_id, 'meta': internal_meta or {}, + 'variables': form_data.variables or {}, 'current_message_id': self.get_current_message_id(form_data.chat), 'created_at': int(time.time()), 'updated_at': int(time.time()), @@ -404,7 +419,6 @@ class ChatTable: chat_item = Chat(**chat.model_dump()) session.add(chat_item) await session.commit() - await session.refresh(chat_item) # Dual-write initial messages to chat_message table try: @@ -482,6 +496,7 @@ class ChatTable: 'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'), 'chat': self._clean_null_bytes(form_data.chat), 'meta': form_data.meta, + 'variables': form_data.variables or {}, '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), @@ -575,6 +590,29 @@ class ChatTable: except Exception: return + async def update_chat_variables_by_id( + self, + id: str, + variables: dict | None, + db: AsyncSession | None = None, + *, + touch: bool = True, + ) -> ChatModel | None: + try: + async with get_async_db_context(db) as session: + chat_item = await session.get(Chat, id) + if chat_item is None: + return None + + chat_item.variables = variables if isinstance(variables, dict) else {} + if touch: + chat_item.updated_at = int(time.time()) + + await session.commit() + return ChatModel.model_validate(chat_item) + except Exception: + return None + async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool: try: async with get_async_db_context(db) as session: @@ -597,7 +635,6 @@ class ChatTable: chat_item.title = clean_title chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title} await session.commit() - await session.refresh(chat_item) return ChatModel.model_validate(chat_item) except Exception: return None @@ -615,7 +652,6 @@ class ChatTable: # Single meta update chat.meta = {**chat.meta, 'tags': new_tag_ids} await session.commit() - await session.refresh(chat) # Batch-create any missing tag rows await Tags.ensure_tags_exist(new_tags, user.id, db=session) @@ -804,6 +840,11 @@ class ChatTable: if chat is None: return None + if not message.get('content'): + output_text = get_output_text(message.get('output')) + if output_text: + message['content'] = output_text + # Sanitize message content for null characters before upserting if isinstance(message.get('content'), str): message['content'] = sanitize_text_for_db(message['content']) @@ -947,7 +988,6 @@ class ChatTable: # Set share_id on the original chat chat.share_id = shared.id await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) # return the updated original # refresh helper @@ -994,7 +1034,6 @@ class ChatTable: chat = await session.get(Chat, id) chat.share_id = share_id await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1007,7 +1046,6 @@ class ChatTable: chat.updated_at = int(time.time()) chat.last_read_at = int(time.time()) await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1021,7 +1059,6 @@ class ChatTable: chat.updated_at = int(time.time()) chat.last_read_at = int(time.time()) await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1327,7 +1364,6 @@ class ChatTable: flag_modified(chat_item, 'chat') if self._sanitize_chat_row(chat_item) or repaired_history: await session.commit() - await session.refresh(chat_item) return ChatModel.model_validate(chat_item) except Exception: @@ -1369,7 +1405,6 @@ class ChatTable: flag_modified(chat, 'chat') if self._sanitize_chat_row(chat) or repaired_history: await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: @@ -1851,7 +1886,6 @@ class ChatTable: chat.last_read_at = int(time.time()) chat.pinned = False await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1931,7 +1965,6 @@ class ChatTable: 'tags': list(set(chat.meta.get('tags', []) + [tag_id])), } await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -2231,7 +2264,6 @@ class ChatTable: return None chat.tasks = tasks await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 5d0b2266b7..00fcfc2e00 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -248,6 +248,10 @@ class Config(Base): now = int(time.time()) new_count = 0 for key, value in defaults.items(): + # Skip keys the DB is not authoritative for (e.g. oauth.* while + # ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths. + if not Config.persistent_enabled_for(key): + continue if key not in existing_keys: value = _json_value(value) db.add(Config(key=key, value=value, updated_at=now)) diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 0bf1a6a139..ca1fe39b38 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -165,7 +165,6 @@ class FeedbackTable: result = Feedback(**feedback.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FeedbackModel.model_validate(result) else: diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 7f29fc5b7d..d1f61c7cc7 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -142,7 +142,6 @@ class FilesTable: result = File(**file.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FileModel.model_validate(result) else: diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 2c011a13c7..573880d521 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -42,7 +42,7 @@ class FunctionMeta(BaseModel): class FunctionModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str type: str content: str @@ -58,7 +58,7 @@ class FunctionModel(BaseModel): # --- form / schema definitions --- class FunctionWithValvesModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str type: str content: str @@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel): class FunctionResponse(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records type: str name: str meta: FunctionMeta @@ -129,7 +129,6 @@ class FunctionsTable: result = Function(**function.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FunctionModel.model_validate(result) else: @@ -275,11 +274,17 @@ class FunctionsTable: result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True)) return [FunctionModel.model_validate(function) for function in result.scalars().all()] + async def get_active_function_ids_by_type( + self, type: str, db: AsyncSession | None = None + ) -> list[tuple[str, bool]]: + """Return (id, is_global) for active functions without fetching plugin source.""" + async with get_async_db_context(db) as db: + result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True)) + return [(id, bool(is_global)) for id, is_global in result.all()] + async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]: """Return (id, is_global) for active filters without fetching plugin source.""" - async with get_async_db_context(db) as db: - result = await db.execute(select(Function.id, Function.is_global).filter_by(type='filter', is_active=True)) - return [(id, bool(is_global)) for id, is_global in result.all()] + return await self.get_active_function_ids_by_type('filter', db=db) async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]: async with get_async_db_context(db) as db: @@ -320,7 +325,6 @@ class FunctionsTable: function.valves = encrypt_valves(valves) function.updated_at = int(time.time()) await db.commit() - await db.refresh(function) return FunctionModel.model_validate(function) except Exception: return None @@ -340,7 +344,6 @@ class FunctionsTable: function.updated_at = int(time.time()) await db.commit() - await db.refresh(function) return FunctionModel.model_validate(function) else: return None diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index d48586a9ca..3163a24f49 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -479,20 +479,16 @@ class KnowledgeTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for knowledge_base in knowledge_bases: - if knowledge_base.user_id == user_id: - result.append(knowledge_base) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='knowledge', - resource_id=knowledge_base.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(knowledge_base) - return result + # One grants query for all non-owned knowledge bases instead of one each + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='knowledge', + resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids] async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: @@ -675,9 +671,25 @@ class KnowledgeTable: async def get_file_metadatas_by_id( self, knowledge_id: str, db: Optional[AsyncSession] = None ) -> list[FileMetadataResponse]: + """Column-only listing: File.data holds each file's full extracted + text, which metadata views must never load.""" try: - files = await self.get_files_by_id(knowledge_id, db=db) - return [FileMetadataResponse(**file.model_dump()) for file in files] + async with get_async_db_context(db) as db: + result = await db.execute( + select(File.id, File.hash, File.meta, File.created_at, File.updated_at) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .filter(KnowledgeFile.knowledge_id == knowledge_id) + ) + return [ + FileMetadataResponse( + id=row.id, + hash=row.hash, + meta=row.meta, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in result.all() + ] except Exception: return [] diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index ad32330f9f..e04f16b962 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -8,7 +8,7 @@ from typing import Literal from open_webui.internal.db import Base, get_async_db_context from pydantic import BaseModel, ConfigDict -from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select +from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +16,7 @@ class Memory(Base): # user memory store """Stores user-created memory entries linked to a vector collection.""" __tablename__ = 'memory' + __table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),) id = Column(String, primary_key=True, unique=True) user_id = Column(String, index=True) @@ -70,7 +71,6 @@ class MemoriesTable: ) db.add(record) await db.commit() - await db.refresh(record) return MemoryModel.model_validate(record) if record else None async def update_memory_by_id_and_user_id( @@ -101,7 +101,6 @@ class MemoriesTable: memory.updated_at = int(time.time()) await db.commit() - await db.refresh(memory) return MemoryModel.model_validate(memory) except Exception: return None diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index ff72dcfc65..5f44d59405 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -220,7 +220,6 @@ class ModelsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) if result: @@ -303,26 +302,26 @@ class ModelsTable: ] async def get_models_by_user_id( - self, user_id: str, permission: str = 'write', db: AsyncSession | None = None + self, + user_id: str, + permission: str = 'write', + db: AsyncSession | None = None, + user_group_ids: set[str] | None = None, ) -> list[ModelUserResponse]: models = await self.get_models(db=db) - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} - result = [] - for model in models: - if model.user_id == user_id: - result.append(model) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='model', - resource_id=model.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(model) - return result + # One grants query for all non-owned models instead of one per model + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='model', + resource_ids=[model.id for model in models if model.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [model for model in models if model.user_id == user_id or model.id in accessible_ids] def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( @@ -530,7 +529,6 @@ class ModelsTable: model.is_active = not model.is_active model.updated_at = int(time.time()) await db.commit() - await db.refresh(model) return await self._to_model_model(model, db=db) except Exception: @@ -562,7 +560,6 @@ class ModelsTable: return None model_obj.updated_at = int(time.time()) await db.commit() - await db.refresh(model_obj) return await self._to_model_model(model_obj, db=db) except Exception as e: log.exception(f'Failed to update the model updated_at by id {id}: {e}') diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 0619bd574a..325f0f5f51 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -128,7 +128,6 @@ class OAuthSessionTable: db.add(result) await db.commit() - await db.refresh(result) if result: # Make a copy of the model data before closing session diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index bb27657032..947d33a133 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -70,7 +70,6 @@ class PromptHistoryTable: ) db.add(history) await db.commit() - await db.refresh(history) return PromptHistoryModel.model_validate(history) async def get_history_by_prompt_id( diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 23a5017acf..61ce0fc218 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -132,7 +132,6 @@ class PromptsTable: ) session.add(record) await session.commit() - await session.refresh(record) # populate generated defaults await AccessGrants.set_access_grants( 'prompt', @@ -169,7 +168,6 @@ class PromptsTable: if history_entry: record.version_id = history_entry.id await session.commit() - await session.refresh(record) # re-read version_id return await self._to_prompt_model(record, db=session) except Exception as e: @@ -637,7 +635,6 @@ class PromptsTable: prompt.is_active = not prompt.is_active prompt.updated_at = int(time.time()) await session.commit() - await session.refresh(prompt) return await self._to_prompt_model(prompt, db=session) return None except Exception: diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 522e1304c3..c3c007cdd9 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -137,7 +137,6 @@ class SkillsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) if result: return await self._to_skill_model(result, db=db) @@ -326,8 +325,8 @@ class SkillsTable: if access_grants is not None: await AccessGrants.set_access_grants('skill', id, access_grants, db=db) - skill = await db.get(Skill, id) - await db.refresh(skill) + # populate_existing: the Core update above bypasses any identity-map copy + skill = await db.get(Skill, id, populate_existing=True) return await self._to_skill_model(skill, db=db) except Exception: return None @@ -343,7 +342,6 @@ class SkillsTable: skill.is_active = not skill.is_active skill.updated_at = int(time.time()) await db.commit() - await db.refresh(skill) return await self._to_skill_model(skill, db=db) except Exception: diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 87f6bac7e3..319f0ec62d 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -63,7 +63,6 @@ class TagTable: record = Tag(id=tag_id, user_id=user_id, name=name) db.add(record) await db.commit() - await db.refresh(record) return TagModel.model_validate(record) if record else None except Exception as e: log.exception('Error inserting tag %r: %s', name, e) diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index a6468f1876..86568288e0 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -41,9 +41,10 @@ class ToolMeta(BaseModel): class ToolModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str - content: str + # None when listed with defer_content=True (source skipped for listings) + content: str | None = None specs: list[dict] meta: ToolMeta access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -65,7 +66,7 @@ class ToolUserModel(ToolModel): class ToolResponse(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str meta: ToolMeta access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -131,7 +132,6 @@ class ToolsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db) if result: return await self._to_tool_model(result, db=db) @@ -171,11 +171,18 @@ class ToolsTable: async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]: async with get_async_db_context(db) as db: - stmt = select(Tool).order_by(Tool.updated_at.desc()) if defer_content: - stmt = stmt - result = await db.execute(stmt) - all_tools = result.scalars().all() + # Skip Tool.content (plugin source, potentially large) via a + # column select; Row attributes satisfy from_attributes. + result = await db.execute( + select( + Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at + ).order_by(Tool.updated_at.desc()) + ) + all_tools = result.all() + else: + result = await db.execute(select(Tool).order_by(Tool.updated_at.desc())) + all_tools = result.scalars().all() user_ids = list(set(tool.user_id for tool in all_tools)) tool_ids = [tool.id for tool in all_tools] @@ -214,20 +221,16 @@ class ToolsTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for tool in tools: - if tool.user_id == user_id: - result.append(tool) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='tool', - resource_id=tool.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(tool) - return result + # One grants query for all non-owned tools instead of one per tool + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='tool', + resource_ids=[tool.id for tool in tools if tool.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids] async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: try: @@ -301,8 +304,8 @@ class ToolsTable: if access_grants is not None: await AccessGrants.set_access_grants('tool', id, access_grants, db=db) - tool = await db.get(Tool, id) - await db.refresh(tool) + # populate_existing: the Core update above bypasses any identity-map copy + tool = await db.get(Tool, id, populate_existing=True) return await self._to_tool_model(tool, db=db) except Exception: return None diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 6f2939d214..1eff4932d5 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import ( JSON, BigInteger, @@ -69,6 +69,7 @@ class User(Base): # identity & profile # Metadata info = Column(JSON, nullable=True) + variables = Column(JSON, nullable=True) settings = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True) scim = Column(JSON, nullable=True) @@ -105,6 +106,7 @@ class UserModel(BaseModel): status_expires_at: int | None = None info: dict | None = None + variables: dict = Field(default_factory=dict, exclude=True) settings: UserSettings | None = None oauth: dict | None = None @@ -126,6 +128,11 @@ class UserModel(BaseModel): self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id) return self + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class UserStatusModel(UserModel): is_active: bool = False @@ -302,7 +309,6 @@ class UsersTable: result = User(**user.model_dump()) session.add(result) await session.commit() - await session.refresh(result) return user if result else None # database read methods @@ -582,7 +588,6 @@ class UsersTable: return None user.role = role await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_status_by_id( @@ -595,7 +600,6 @@ class UsersTable: for key, value in form_data.model_dump(exclude_none=True).items(): setattr(user, key, value) await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_profile_image_url_by_id( @@ -615,7 +619,6 @@ class UsersTable: return None user.profile_image_url = profile_image_url await session.commit() - await session.refresh(user) return UserModel.model_validate(user) @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) @@ -636,7 +639,6 @@ class UsersTable: oauth[provider] = {'sub': sub} user.oauth = oauth await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_scim_by_id( @@ -655,7 +657,6 @@ class UsersTable: scim[provider] = {'external_id': external_id} user.scim = scim await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None: @@ -666,7 +667,6 @@ class UsersTable: for key, value in updated.items(): setattr(user, key, value) await session.commit() - await session.refresh(user) return UserModel.model_validate(user) # settings update helper @@ -681,7 +681,6 @@ class UsersTable: user_settings.update(updated) user.settings = user_settings await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool: diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2a6ac5c3e1..f816efb8fc 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -51,7 +51,7 @@ from open_webui.retrieval.web.utils import get_web_loader from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.headers import include_user_info_headers -from open_webui.utils.misc import get_message_list +from open_webui.utils.misc import get_content_from_message, get_message_list log = logging.getLogger(__name__) @@ -73,6 +73,20 @@ LOADER_CONFIG_KEYS = { 'web_loader_ssl_verification': 'web.loader.ssl_verification', 'web_loader_concurrent_requests': 'web.loader.concurrent_requests', 'web_search_trust_env': 'web.search.trust_env', + 'web_loader_engine': 'web.loader.engine', + 'web_loader_timeout': 'web.loader.timeout', + 'playwright_ws_url': 'web.loader.playwright_ws_url', + 'playwright_timeout': 'web.loader.playwright_timeout', + 'firecrawl_api_key': 'web.loader.firecrawl_api_key', + 'firecrawl_api_url': 'web.loader.firecrawl_api_url', + 'firecrawl_timeout': 'web.loader.firecrawl_timeout', + 'tavily_api_key': 'web.search.tavily_api_key', + 'tavily_extract_depth': 'web.search.tavily_extract_depth', + 'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url', + 'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key', + 'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language', + 'external_web_loader_url': 'web.loader.external_web_loader_url', + 'external_web_loader_api_key': 'web.loader.external_web_loader_api_key', 'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine', 'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key', 'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url', @@ -128,6 +142,7 @@ def get_loader(request, url: str, config: dict): verify_ssl=config.get('web_loader_ssl_verification'), requests_per_second=config.get('web_loader_concurrent_requests'), trust_env=config.get('web_search_trust_env'), + loader_config=config, ) @@ -1409,7 +1424,10 @@ async def get_sources_from_items( # Reconstruct the message list in order message_list = get_message_list(messages_map, message_id) message_history = '\n'.join( - [f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list] + [ + f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n' + for m in message_list + ] ) # User has access to the chat diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 0cb10eb9a6..22fe7d98ae 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -158,6 +158,11 @@ def _ssrf_safe_new_conn(self): if getattr(self, 'source_address', None): sock.bind(self.source_address) for opt in getattr(self, 'socket_options', None) or (): + if len(opt) == 4 and isinstance(opt[3], str): + # urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp") + if opt[3].lower() == 'tcp': + sock.setsockopt(*opt[:3]) + continue sock.setsockopt(*opt) sock.connect(sa) return sock @@ -785,13 +790,11 @@ class SafeWebBaseLoader(WebBaseLoader): final_results = [] for i, result in enumerate(results): url = urls[i] - if parser is None: - if url.endswith('.xml'): - parser = 'xml' - else: - parser = self.default_parser - self._check_parser(parser) - final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs)) + url_parser = parser + if url_parser is None: + url_parser = 'xml' if url.endswith('.xml') else self.default_parser + self._check_parser(url_parser) + final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs)) return final_results async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]: @@ -838,6 +841,7 @@ def get_web_loader( verify_ssl: bool = True, requests_per_second: int = 2, trust_env: bool = False, + loader_config: Optional[dict] = None, ): # Check if the URLs are valid safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) @@ -846,6 +850,16 @@ def get_web_loader( log.warning(f'All provided URLs were blocked or invalid: {urls}') raise ValueError(ERROR_MESSAGES.INVALID_URL) + loader_config = loader_config or {} + + def cfg(key, env_value): + # Admin-saved DB value wins; env constant covers keys never saved. + value = loader_config.get(key) + return env_value if value is None else value + + engine = cfg('web_loader_engine', WEB_LOADER_ENGINE) + web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT) + web_loader_args = { 'web_paths': safe_urls, 'verify_ssl': verify_ssl, @@ -854,13 +868,15 @@ def get_web_loader( 'trust_env': trust_env, } - if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web': + WebLoaderClass = None + + if engine == '' or engine == 'safe_web': WebLoaderClass = SafeWebBaseLoader request_kwargs = {} - if WEB_LOADER_TIMEOUT: + if web_loader_timeout: try: - timeout_value = float(WEB_LOADER_TIMEOUT) + timeout_value = float(web_loader_timeout) except ValueError: timeout_value = None @@ -870,42 +886,44 @@ def get_web_loader( if request_kwargs: web_loader_args['requests_kwargs'] = request_kwargs - if WEB_LOADER_ENGINE == 'playwright': + if engine == 'playwright': WebLoaderClass = SafePlaywrightURLLoader - web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT - if PLAYWRIGHT_WS_URL: - web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL + web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT) + playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL) + if playwright_ws_url: + web_loader_args['playwright_ws_url'] = playwright_ws_url - if WEB_LOADER_ENGINE == 'firecrawl': + if engine == 'firecrawl': WebLoaderClass = SafeFireCrawlLoader - web_loader_args['api_key'] = FIRECRAWL_API_KEY - web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL - if FIRECRAWL_TIMEOUT: + web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY) + web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL) + firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT) + if firecrawl_timeout: try: - web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT) + web_loader_args['timeout'] = int(firecrawl_timeout) except ValueError: pass - if WEB_LOADER_ENGINE == 'tavily': + if engine == 'tavily': WebLoaderClass = SafeTavilyLoader - web_loader_args['api_key'] = TAVILY_API_KEY - web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH + web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY) + web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH) - if WEB_LOADER_ENGINE == 'microsoft_web_iq': + if engine == 'microsoft_web_iq': WebLoaderClass = SafeMicrosoftWebIQLoader - web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL - web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY - web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE - if WEB_LOADER_TIMEOUT: + web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL) + web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY) + web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE) + if web_loader_timeout: try: - web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT) + web_loader_args['timeout'] = int(web_loader_timeout) except ValueError: pass - if WEB_LOADER_ENGINE == 'external': + if engine == 'external': WebLoaderClass = ExternalWebLoader - web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL - web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY + web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL) + web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY) if WebLoaderClass: web_loader = WebLoaderClass(**web_loader_args) @@ -919,6 +937,6 @@ def get_web_loader( return web_loader else: raise ValueError( - f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. ' + f'Invalid WEB_LOADER_ENGINE: {engine}. ' "Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'." ) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 47dec5fa4c..1599852f3c 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -49,6 +49,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + AIOHTTP_FILE_STREAM_CHUNK_SIZE, BYPASS_PYDUB_PREPROCESSING, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, @@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, for key, value in payload.items(): form_data.add_field(key, str(value)) - with open(file_path, 'rb') as audio_file: - form_data.add_field('file', audio_file, filename=filename) + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk - r = await session.post( - url=f'{api_base_url}/audio/transcriptions', - headers=headers, - data=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) + form_data.add_field('file', audio_chunks(), filename=filename) + + r = await session.post( + url=f'{api_base_url}/audio/transcriptions', + headers=headers, + data=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) if r.status == 200: break @@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): base_url or f'https://{region}.api.cognitive.microsoft.com' ) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15' - form_data = aiohttp.FormData() - form_data.add_field('definition', definition) - form_data.add_field('audio', open(file_path, 'rb'), filename=filename) - r = None try: session = await get_session() + form_data = aiohttp.FormData() + form_data.add_field('definition', definition) + + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field('audio', audio_chunks(), filename=filename) r = await session.post( url=endpoint, data=form_data, @@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, if language: form_data.add_field('language', language) - form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type) + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type) r = await session.post( url=f'{api_base_url}/audio/transcriptions', @@ -1094,7 +1109,7 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict] for chunk_path in chunk_paths: if chunk_path != file_path and os.path.isfile(chunk_path): try: - os.remove(chunk_path) + await asyncio.to_thread(os.remove, chunk_path) except Exception: pass @@ -1208,12 +1223,8 @@ async def transcription( if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)): raise ValueError('Invalid file path detected') - def _write_upload(): - with open(file_path, 'wb') as f: - f.write(contents) - - # Audio uploads can be large; write to disk off the event loop. - await asyncio.to_thread(_write_upload) + async with aiofiles.open(file_path, 'wb') as f: + await f.write(contents) try: metadata = None diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 6b891bdadf..64413fa74e 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1500,12 +1500,13 @@ async def update_message_by_id( if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if ( - user.role != 'admin' - and message.user_id != user.id - and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db) + if user.role != 'admin' and not await channel_has_access( + user.id, channel, permission='write', strict=False, db=db ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Write access is not authorship — block cross-member edits. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: await Messages.update_message_by_id(message_id, form_data, db=db) @@ -1725,18 +1726,17 @@ async def delete_message_by_id( if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if ( - user.role != 'admin' - and message.user_id != user.id - and not await channel_has_access( - user.id, - channel, - permission='write', - strict=False, - db=db, - ) + if user.role != 'admin' and not await channel_has_access( + user.id, + channel, + permission='write', + strict=False, + db=db, ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Write access is not authorship — block cross-member deletes. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: await Messages.delete_message_by_id(message_id, db=db) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 04d67c5e41..1a707607e0 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -55,6 +55,7 @@ CHAT_CONFIG_KEYS = { 'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable', 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold', 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap', + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', } @@ -76,6 +77,7 @@ class ChatConfigForm(BaseModel): ENABLE_CONTEXT_COMPACTION: bool CONTEXT_COMPACTION_TOKEN_THRESHOLD: int CONTEXT_COMPACTION_TOKEN_CAP: int | None = None + CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str @@ -125,6 +127,8 @@ async def get_chat_config_values() -> dict: config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values} if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None: config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000) + if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None: + config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40 return config @@ -738,12 +742,14 @@ async def get_chat_config(user=Depends(get_admin_user)): async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)): threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD)) token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold)) + retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE))) await Config.upsert( chat_config_updates( { **form_data.model_dump(), 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold, 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap, + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage, } ) ) @@ -1203,30 +1209,31 @@ async def compact_chat_by_id( async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - if not chat: - # Check if user has access via access grants (shared_chat grants) - if user.role == 'admin': - candidate = await Chats.get_chat_by_id(id, db=db) - if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)): - chat = candidate - else: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=id, - permission='read', - db=db, - ) - if has_grant: - chat = await Chats.get_chat_by_id(id, db=db) + if not chat and user.role == 'admin': + candidate = await Chats.get_chat_by_id(id, db=db) + if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)): + chat = candidate - # Check folder-based access (shared folders) - if not chat: - candidate = await Chats.get_chat_by_id(id, db=db) - if candidate and candidate.folder_id: - folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) - if folder and await has_folder_access(user.id, folder, 'read', db): - chat = candidate + # Access explicitly granted to this user applies to admins too, so an admin + # does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off. + if not chat: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) + + # Check folder-based access (shared folders) + if not chat: + candidate = await Chats.get_chat_by_id(id, db=db) + if candidate and candidate.folder_id: + folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) + if folder and await has_folder_access(user.id, folder, 'read', db): + chat = candidate if chat: data = ChatResponse(**chat.model_dump()).model_dump() @@ -1259,6 +1266,8 @@ async def update_chat_by_id( ) chat = await Chats.update_chat_by_id(id, updated_chat, db=db) + if form_data.variables is not None: + chat = await Chats.update_chat_variables_by_id(id, form_data.variables, db=db) or chat # Reconcile chat_message rows without inferring deletes from missing IDs. # Message deletion has its own endpoint below. @@ -1642,6 +1651,9 @@ async def fork_chat_by_id( internal_meta=meta, ) + if fork and chat.variables: + fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork + if not fork: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT()) @@ -1684,6 +1696,7 @@ async def clone_chat_by_id( **{ 'chat': updated_chat, 'meta': chat.meta, + 'variables': chat.variables or {}, 'pinned': chat.pinned, 'folder_id': chat.folder_id, } @@ -1767,6 +1780,7 @@ async def clone_shared_chat_by_id( **{ 'chat': updated_chat, 'meta': chat.meta, + 'variables': chat.variables or {}, 'pinned': chat.pinned, 'folder_id': chat.folder_id, } diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index fd18843435..8a6bf71f4a 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -424,7 +424,7 @@ async def create_feedback( EVENTS.FEEDBACK_CREATED, actor=user, subject_id=feedback.id, - data={'rating': getattr(feedback, 'rating', None)}, + data={'rating': (feedback.data or {}).get('rating')}, ) return feedback @@ -463,7 +463,7 @@ async def update_feedback_by_id( EVENTS.FEEDBACK_UPDATED, actor=user, subject_id=feedback.id, - data={'rating': getattr(feedback, 'rating', None)}, + data={'rating': (feedback.data or {}).get('rating')}, ) return feedback diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 6020c10450..8e85eccc27 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -76,7 +76,6 @@ async def get_folders( await check_folders_permission(request, user, db=db) folders = await Folders.get_folders_by_user_id(user.id, db=db) - # The full folder set is already loaded; parents can be validated against it folder_ids = {folder.id for folder in folders} # Verify folder data integrity diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index d26734da64..6820e96ab6 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -13,6 +13,7 @@ from types import SimpleNamespace from typing import Optional from urllib.parse import quote, urlparse +import aiofiles import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi.responses import FileResponse @@ -940,10 +941,10 @@ async def image_edits( if isinstance(file_response, FileResponse): file_path = file_response.path - with open(file_path, 'rb') as f: - file_bytes = f.read() - image_data = base64.b64encode(file_bytes).decode('utf-8') - mime_type, _ = mimetypes.guess_type(file_path) + async with aiofiles.open(file_path, 'rb') as f: + file_bytes = await f.read() + image_data = base64.b64encode(file_bytes).decode('utf-8') + mime_type, _ = mimetypes.guess_type(file_path) return f'data:{mime_type};base64,{image_data}' return data diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 517d3e76e3..8af1afbdce 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -40,6 +40,7 @@ from open_webui.models.models import ( from open_webui.utils.access_control import filter_allowed_access_grants, has_permission from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.chat_variables import get_chat_variables_schema from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -48,6 +49,14 @@ log = logging.getLogger(__name__) router = APIRouter() +def add_chat_variables_schema(model_dict: dict) -> dict: + system = ((model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None) + schema = get_chat_variables_schema(system) + if schema: + model_dict.setdefault('meta', {})['chat_variables_schema'] = schema + return model_dict + + def _safe_static_redirect_path(url: str) -> str | None: """ If url is a same-origin static asset path, return a normalized path safe for @@ -177,7 +186,7 @@ async def get_models( # Strip profile_image_url from meta — images are served via /model/profile/image. items = [] for model in result.items: - data = model.model_dump() + data = add_chat_variables_schema(model.model_dump()) if data.get('meta'): data['meta'].pop('profile_image_url', None) items.append( @@ -527,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes db=db, ): model_dict = model.model_dump() + model_dict = add_chat_variables_schema(model_dict) # Strip params (system prompt and other admin-curated config) # for read-only callers — matches the params strip already # enforced on /api/models in utils/models.py. Owners, admins diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index eec46d6bc8..ff1bc3a19a 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -11,6 +11,7 @@ from datetime import datetime from typing import Optional, Union from urllib.parse import urlparse +import aiofiles import aiohttp from aiocache import cached from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile @@ -25,6 +26,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, 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, @@ -1529,7 +1531,7 @@ async def download_file_stream( file_url: str, file_path: str, file_name: str, - chunk_size: int = 1024 * 1024, + chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE, ): """Stream a model file download from *file_url*, then push the blob to Ollama.""" current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 @@ -1544,37 +1546,38 @@ async def download_file_stream( ) as response: total_size = int(response.headers.get('content-length', 0)) + current_size - with open(file_path, 'ab+') as f: + async with aiofiles.open(file_path, 'ab') as f: async for data in response.content.iter_chunked(chunk_size): current_size += len(data) - f.write(data) + await f.write(data) - done = current_size == total_size - progress = round((current_size / total_size) * 100, 2) + progress_total = total_size or current_size + progress = round((current_size / progress_total) * 100, 2) yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n' - if done: - f.close() - hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) + done = True + hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) - def _read_blob(): - with open(file_path, 'rb') as blob_f: - return blob_f.read() + blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' + blob_size = await asyncio.to_thread(os.path.getsize, file_path) - blob_data = await asyncio.to_thread(_read_blob) + async def blob_chunks(): + async with aiofiles.open(file_path, 'rb') as blob_file: + while chunk := await blob_file.read(chunk_size): + yield chunk - blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' - async with session.post( - blob_url, - data=blob_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=30), - ) as blob_resp: - if blob_resp.ok: - os.remove(file_path) - yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' - else: - raise RuntimeError('Ollama: Could not create blob, Please try again.') + async with session.post( + blob_url, + data=blob_chunks(), + headers={'Content-Length': str(blob_size)}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=30), + ) as blob_resp: + if blob_resp.ok: + await asyncio.to_thread(os.remove, file_path) + yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' + else: + raise RuntimeError('Ollama: Could not create blob, Please try again.') @router.post('/models/download') @@ -1621,17 +1624,11 @@ async def upload_model( os.makedirs(UPLOAD_DIR, exist_ok=True) # Stage 1: persist the uploaded file to disk - chunk_size = 1024 * 1024 * 2 # 2 MiB + chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE - def _persist_upload(): - with open(file_path, 'wb') as out_f: - while True: - chunk = file.file.read(chunk_size) - if not chunk: - break - out_f.write(chunk) - - await asyncio.to_thread(_persist_upload) + async with aiofiles.open(file_path, 'wb') as out_f: + while chunk := await file.read(chunk_size): + await out_f.write(chunk) async def file_process_stream(): nonlocal ollama_url @@ -1643,25 +1640,26 @@ async def upload_model( log.info(f'Model Hash: {file_hash}') try: - with open(file_path, 'rb') as f: - bytes_read = 0 - while chunk := f.read(chunk_size): + bytes_read = 0 + async with aiofiles.open(file_path, 'rb') as f: + while chunk := await f.read(chunk_size): bytes_read += len(chunk) progress = round(bytes_read / total_size * 100, 2) - yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n' - - # Stage 3: push blob to Ollama - def _read_blob(): - with open(file_path, 'rb') as f: - return f.read() - - blob_data = await asyncio.to_thread(_read_blob) + event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read}) + yield f'data: {event}\n\n' session = await get_session() blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}' + + async def blob_chunks(): + async with aiofiles.open(file_path, 'rb') as blob_file: + while chunk := await blob_file.read(chunk_size): + yield chunk + async with session.post( blob_url, - data=blob_data, + data=blob_chunks(), + headers={'Content-Length': str(total_size)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as resp: @@ -1669,7 +1667,7 @@ async def upload_model( raise Exception('Ollama: Could not create blob, Please try again.') log.info('Uploaded to /api/blobs') - os.remove(file_path) + await asyncio.to_thread(os.remove, file_path) # Stage 4: create the model model, _ext = os.path.splitext(filename) @@ -1690,7 +1688,10 @@ async def upload_model( ) as create_resp: if create_resp.ok: log.info('API SUCCESS!') - yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n' + event = json.dumps( + {'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model} + ) + yield f'data: {event}\n\n' else: resp_text = await create_resp.text() raise Exception(f'Failed to create model in Ollama. {resp_text}') diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index ba94d11a65..5d0f9062ed 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -8,6 +8,7 @@ import re from typing import Optional from urllib.parse import quote, urlparse +import aiofiles import aiohttp from aiocache import cached from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -491,13 +492,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): r.raise_for_status() - # Save the streaming content to a file - with open(file_path, 'wb') as f: + async with aiofiles.open(file_path, 'wb') as f: async for chunk in r.content.iter_chunked(8192): - f.write(chunk) + await f.write(chunk) - with open(file_body_path, 'w') as f: - json.dump(json.loads(body.decode('utf-8')), f) + async with aiofiles.open(file_body_path, 'w') as f: + await f.write(json.dumps(json.loads(body.decode('utf-8')))) # Return the saved file return FileResponse(file_path) diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 604e45375d..bffc7dd012 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -1,9 +1,9 @@ import asyncio import logging import os -import shutil from typing import Optional +import aiofiles import aiohttp from fastapi import ( APIRouter, @@ -18,7 +18,7 @@ from fastapi import ( ) from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE from open_webui.events import EVENTS, publish_event from open_webui.models.config import Config from open_webui.routers.openai import get_all_models_responses @@ -237,35 +237,37 @@ async def upload_pipeline( response = None try: - # Save the uploaded file off the event loop (uploads can be large). - def _save_upload(): - with open(file_path, 'wb') as buffer: - shutil.copyfileobj(file.file, buffer) - - await asyncio.to_thread(_save_upload) + async with aiofiles.open(file_path, 'wb') as buffer: + while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + await buffer.write(chunk) url, key = await get_openai_connection(urlIdx) headers = {'Authorization': f'Bearer {key}'} async with aiohttp.ClientSession(trust_env=True) as session: - with open(file_path, 'rb') as f: - form_data = aiohttp.FormData() - form_data.add_field( - 'file', - f, - filename=filename, - content_type='application/octet-stream', - ) + form_data = aiohttp.FormData() - async with session.post( - f'{url}/pipelines/upload', - headers=headers, - data=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - response.raise_for_status() - data = await response.json() + async def pipeline_chunks(): + async with aiofiles.open(file_path, 'rb') as pipeline_file: + while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field( + 'file', + pipeline_chunks(), + filename=filename, + content_type='application/octet-stream', + ) + + async with session.post( + f'{url}/pipelines/upload', + headers=headers, + data=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() await publish_event( request, @@ -297,7 +299,7 @@ async def upload_pipeline( finally: # Ensure the file is deleted after the upload is completed or on failure if os.path.exists(file_path): - os.remove(file_path) + await asyncio.to_thread(os.remove, file_path) class AddPipelineForm(BaseModel): diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 3f62af4af4..9116dc80de 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -127,6 +127,8 @@ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +TIKTOKEN_DISALLOWED_SPECIAL = () + ########################################## # # Utility functions @@ -1582,7 +1584,7 @@ def get_splitter_length_function( ) -> Callable[[str], int]: if config.TEXT_SPLITTER == 'token': encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME)) - return lambda text: len(encoding.encode(text, disallowed_special=())) + return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL)) if config.TEXT_SPLITTER == 'token_transformers': tokenizer = get_transformers_tokenizer(request, config) @@ -1689,6 +1691,7 @@ def save_docs_to_vector_db( chunk_size=config.CHUNK_SIZE, chunk_overlap=config.CHUNK_OVERLAP, add_start_index=True, + disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL, ) docs = text_splitter.split_documents(docs) elif config.TEXT_SPLITTER == 'token_transformers': @@ -2609,6 +2612,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION, requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS, trust_env=config.WEB_SEARCH_TRUST_ENV, + loader_config=await get_loader_config(), ) docs = await loader.aload() @@ -2910,21 +2914,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo folder = f'{UPLOAD_DIR}' try: # Check if the directory exists - if os.path.exists(folder): + if await asyncio.to_thread(os.path.exists, folder): # Iterate over all the files and directories in the specified directory - for filename in os.listdir(folder): + for filename in await asyncio.to_thread(os.listdir, folder): file_path = os.path.join(folder, filename) try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) # Remove the file or link - elif os.path.isdir(file_path): - shutil.rmtree(file_path) # Remove the directory + if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread( + os.path.islink, file_path + ): + await asyncio.to_thread(os.unlink, file_path) # Remove the file or link + elif await asyncio.to_thread(os.path.isdir, file_path): + await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory except Exception as e: log.exception(f'Failed to delete {file_path}. Reason: {e}') else: log.warning(f'The directory {folder} does not exist') except Exception as e: log.exception(f'Failed to process the directory {folder}. Reason: {e}') + await publish_event( request, EVENTS.RETRIEVAL_UPLOADS_RESET, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index cff598b236..54559c748e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -44,6 +44,7 @@ from open_webui.utils.auth import ( get_verified_user, validate_password, ) +from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -207,6 +208,7 @@ class SharingPermissions(BaseModel): class AccessGrantsPermissions(BaseModel): allow_users: bool = True + allow_groups: bool = True class ChatPermissions(BaseModel): @@ -573,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy return user.info +class UserVariablesForm(BaseModel): + variables: dict = Field(default_factory=dict) + + +class UserVariablesResponse(BaseModel): + variables: dict[str, str] = Field(default_factory=dict) + + +############################ +# GetUserVariablesBySessionUser +############################ + + +@router.get('/user/variables', response_model=UserVariablesResponse) +async def get_user_variables_by_session_user(user=Depends(get_verified_user)): + return UserVariablesResponse(variables=normalize_user_variables(user.variables)) + + +############################ +# UpdateUserVariablesBySessionUser +############################ + + +@router.post('/user/variables/update', response_model=UserVariablesResponse) +async def update_user_variables_by_session_user( + form_data: UserVariablesForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + try: + variables = validate_user_variables(form_data.variables) + except ChatVariablesError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db) + if not updated: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + return UserVariablesResponse(variables=variables) + + ############################ # UpdateUserInfoBySessionUser ############################ diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 0fbc1fa235..67fbd5bcdd 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -5,7 +5,6 @@ import logging import random import sys import time -from typing import Dict import pycrdt as Y import socketio @@ -16,7 +15,6 @@ from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX, - VERSION, WEBSOCKET_EVENT_CALLER_TIMEOUT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_CLUSTER, @@ -44,7 +42,6 @@ from open_webui.utils.redis import ( get_redis_connection, get_sentinels_from_env, ) -from redis import asyncio as aioredis logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -58,6 +55,11 @@ REDIS = None SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN +def get_room_sid_map(manager, namespace: str, room: str): + """Return this process's Socket.IO sid map for a room, without copying it.""" + return manager.rooms.get(namespace, {}).get(room) + + class LocalFilteredRedisManager(socketio.AsyncRedisManager): """AsyncRedisManager that drops pub/sub emits with no local recipients. @@ -76,7 +78,7 @@ class LocalFilteredRedisManager(socketio.AsyncRedisManager): room = message.get('room') if isinstance(room, str): namespace = message.get('namespace') or '/' - if next(self.get_participants(namespace, room), None) is None: + if not get_room_sid_map(self, namespace, room): return await super()._handle_emit(message) @@ -229,17 +231,19 @@ async def periodic_usage_pool_cleanup(): try: while True: if not renew_func(): - log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.') + log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.') raise Exception('Unable to renew usage pool cleanup lock.') now = int(time.time()) - send_usage = False for model_id, connections in list(USAGE_POOL.items()): # Creating a list of sids to remove if they have timed out expired_sids = [ sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION ] + if connections and not expired_sids: + continue + for sid in expired_sids: del connections[sid] @@ -248,8 +252,6 @@ async def periodic_usage_pool_cleanup(): del USAGE_POOL[model_id] else: USAGE_POOL[model_id] = connections - - send_usage = True await asyncio.sleep(TIMEOUT_DURATION) finally: release_func() @@ -276,24 +278,21 @@ def get_user_id_from_session_pool(sid): def get_session_ids_from_room(room): """Get all session IDs from a specific room.""" - active_session_ids = sio.manager.get_participants( - namespace='/', - room=room, - ) - return [session_id[0] for session_id in active_session_ids] + members = get_room_sid_map(sio.manager, '/', room) + return list(members) if members else [] def get_user_ids_from_room(room): active_session_ids = get_session_ids_from_room(room) + # Single pool lookup per session (each .get is a Redis round trip + # when the session pool is Redis-backed). active_user_ids = list( - set( - [ - SESSION_POOL.get(session_id)['id'] - for session_id in active_session_ids - if SESSION_POOL.get(session_id) is not None - ] - ) + { + entry['id'] + for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids) + if entry is not None + } ) return active_user_ids @@ -357,7 +356,7 @@ async def usage(sid, data): # Store the new usage data and task USAGE_POOL[model_id] = { - **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), + **(USAGE_POOL.get(model_id) or {}), sid: {'updated_at': current_time}, } @@ -404,33 +403,38 @@ async def user_join(sid, data): if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis): return - user = await Users.get_user_by_id(token_data['id']) - if not user: - return + existing = SESSION_POOL.get(sid) + if existing and existing.get('id') == token_data['id']: + SESSION_POOL[sid] = {**existing, 'last_seen_at': int(time.time())} + user_id, user_name, user_role = existing['id'], existing['name'], existing['role'] + else: + user = await Users.get_user_by_id(token_data['id']) + if not user: + return - SESSION_POOL[sid] = { - **user.model_dump( - exclude=[ - 'profile_image_url', - 'profile_banner_image_url', - 'date_of_birth', - 'bio', - 'gender', - ] - ), - 'last_seen_at': int(time.time()), - } - - await sio.enter_room(sid, f'user:{user.id}') + SESSION_POOL[sid] = { + **user.model_dump( + exclude=[ + 'profile_image_url', + 'profile_banner_image_url', + 'date_of_birth', + 'bio', + 'gender', + ] + ), + 'last_seen_at': int(time.time()), + } + await sio.enter_room(sid, f'user:{user.id}') + user_id, user_name, user_role = user.id, user.name, user.role # Join all the channels only if user has channels permission - if user.role == 'admin' or await has_permission(user.id, 'features.channels'): - channels = await Channels.get_channels_by_user_id(user.id) + if user_role == 'admin' or await has_permission(user_id, 'features.channels'): + channels = await Channels.get_channels_by_user_id(user_id) log.debug(f'{channels=}') for channel in channels: await sio.enter_room(sid, f'channel:{channel.id}') - return {'id': user.id, 'name': user.name} + return {'id': user_id, 'name': user_name} @sio.on('heartbeat') @@ -510,13 +514,7 @@ async def join_note(sid, data): @sio.on('events:channel') async def channel_events(sid, data): room = f'channel:{data["channel_id"]}' - participants = sio.manager.get_participants( - namespace='/', - room=room, - ) - - sids = [sid for sid, _ in participants] - if sid not in sids: + if sid not in (get_room_sid_map(sio.manager, '/', room) or {}): return event_data = data['data'] @@ -859,7 +857,6 @@ async def yjs_awareness_update(sid, data): @sio.event async def disconnect(sid, reason=None): if sid in SESSION_POOL: - user = SESSION_POOL[sid] del SESSION_POOL[sid] # Clean up USAGE_POOL entries for this session @@ -960,16 +957,19 @@ async def get_event_emitter(request_info, update_db=True): if internal and event_data.get('type') == 'notification': return - await sio.emit( - 'events', - { - 'chat_id': chat_id, - 'message_id': message_id, - **({'internal': True} if internal else {}), - 'data': event_data, - }, - room=f'user:{user_id}', - ) + room = f'user:{user_id}' + # Local rooms are authoritative; Redis may have listeners on another instance. + if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}): + await sio.emit( + 'events', + { + 'chat_id': chat_id, + 'message_id': message_id, + **({'internal': True} if internal else {}), + 'data': event_data, + }, + room=room, + ) if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'): event_type = event_data.get('type') diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index b337b08f40..010d1f34f6 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -102,12 +102,18 @@ class RedisDict: # Serialize values once — reused for both the fingerprint and the write. serialized = {k: json.dumps(v) for k, v in mapping.items()} + digest = hashlib.sha256() + for key in sorted(serialized): + digest.update(key.encode()) + digest.update(b'\0') + digest.update(serialized[key].encode()) + digest.update(b'\0') + signature = digest.hexdigest() # Skip the write when the prepared mapping is identical to the last one # this process wrote. The check is per-instance (not distributed), but # still eliminates the majority of redundant writes because each pod # typically produces the same model list on consecutive refreshes. - signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest() if signature == self._last_signature: return diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index 44d092e792..704ea45b1f 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -33,9 +33,9 @@ MAX_GREP_MATCHES = 50 def is_regex_pattern(pattern: str) -> bool: - """Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...]).""" + """Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...]).""" return ( - '\|' in pattern + '|' in pattern or '.*' in pattern or '.+' in pattern or '.?' in pattern @@ -691,6 +691,10 @@ async def _kb_grep( for i, line in enumerate(lines, 1): if _matches(line): matched.append(f'{i}: {line}') + if count_only: + return str(len(matched)) + if filenames_only: + return '(standard input)' if matched else f'No matches for "{pattern}"' return '\n'.join(matched) if matched else f'No matches for "{pattern}"' # Single file grep diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index d0345ccdd5..975008b4c1 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -276,6 +276,7 @@ async def has_base_model_access( user_id: str, model_info, *, + user_role: str | None = None, user_group_ids: set[str] | None = None, db=None, ) -> bool: @@ -283,9 +284,11 @@ async def has_base_model_access( Walk the ``base_model_id`` chain and verify the caller has read access at every hop. - Returns ``True`` when access is granted (or the chain ends at a raw - provider model that has no per-model ACL). Returns ``False`` the - moment a registered base model denies access. + A base model without a ``model`` table row is admin-only, matching how + unregistered models are treated for direct use (``get_filtered_models`` + hides them from non-admins and ``check_model_access`` rejects them), so + a shared preset cannot be used to reach a base model the caller could + not use directly. Returns ``False`` the moment any hop denies access. """ from open_webui.models.access_grants import AccessGrants from open_webui.models.models import Models @@ -296,7 +299,7 @@ async def has_base_model_access( seen.add(base_model_id) base_model_info = await Models.get_model_by_id(base_model_id, db=db) if base_model_info is None: - break # Raw provider model — no per-model ACL + return user_role == 'admin' if not ( user_id == base_model_info.user_id or await AccessGrants.has_access( @@ -355,7 +358,9 @@ async def check_model_access( raise HTTPException(status_code=403, detail='Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): + if not await has_base_model_access( + user.id, model_info, user_role=user.role, user_group_ids=user_group_ids + ): raise HTTPException(status_code=403, detail='Model not found') else: if user.role != 'admin': diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index ddb6acb066..f015e71c70 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -18,6 +18,7 @@ async def has_access_to_file( access_type: str, user: UserModel, db: AsyncSession | None = None, + user_group_ids: set[str] | None = None, ) -> bool: """ Check if a user has the specified access to a file through any of: @@ -43,7 +44,8 @@ async def has_access_to_file( # the object's OWNER owns that file; otherwise a read-only file laundered into an object # the user controls would gain write/delete on it (CWE-863). Read access is unaffected. knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db) - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} for knowledge_base in knowledge_bases: if ( knowledge_base.user_id == user.id @@ -60,12 +62,25 @@ async def has_access_to_file( knowledge_base_id = file.meta.get('collection_name') if file.meta else None if knowledge_base_id: - knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db) - for knowledge_base in knowledge_bases: - if knowledge_base.id == knowledge_base_id and ( - access_type == 'read' or knowledge_base.user_id == file.user_id - ): - return True + # Fetch the one referenced knowledge base instead of listing every + # knowledge base the user can access just to scan for this id. + knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db) + if ( + knowledge_base + and (access_type == 'read' or knowledge_base.user_id == file.user_id) + and ( + knowledge_base.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='knowledge', + resource_id=knowledge_base.id, + permission=access_type, + user_group_ids=user_group_ids, + db=db, + ) + ) + ): + return True # Check if the file is associated with any channels the user has access to channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) @@ -88,7 +103,9 @@ async def has_access_to_file( # Check if the file is directly attached to a shared workspace model (per the ownership # note above, model write is conferred only for files the model owner owns). - for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): + for model in await Models.get_models_by_user_id( + user.id, permission=access_type, db=db, user_group_ids=user_group_ids + ): knowledge_items = getattr(model.meta, 'knowledge', None) or [] for item in knowledge_items: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: @@ -105,14 +122,18 @@ async def get_accessible_folder_files( ) -> list[dict]: """Filter folder.data['files'] entries to those the caller can read. - Each entry is expected to have 'type' ('file' or 'collection') and 'id'. - Admins bypass all checks. Unknown types are kept as-is. + Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and + note ids are each access-checked against the caller; admins bypass all checks and + genuinely unknown types are kept as-is. """ if not entries: return [] if user.role == 'admin': return list(entries) + # One group-membership fetch for the whole folder listing + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + accessible: list[dict] = [] for entry in entries: if not isinstance(entry, dict): @@ -123,11 +144,27 @@ async def get_accessible_folder_files( accessible.append(entry) continue if entry_type == 'file': - if await has_access_to_file(entry_id, 'read', user, db=db): + if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids): accessible.append(entry) elif entry_type == 'collection': if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db): accessible.append(entry) + elif entry_type == 'note': + # Owner has no self-grant (notes are private by default), so check ownership too. + from open_webui.models.notes import Notes + + note = await Notes.get_note_by_id(entry_id, db=db) + if note and ( + note.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=entry_id, + permission='read', + db=db, + ) + ): + accessible.append(entry) else: accessible.append(entry) return accessible diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 7189bfcad8..d28abc90f8 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -100,14 +100,20 @@ class CommitSessionMiddleware: # Downstream did not complete successfully. Roll back any # pending sync writes, release the connection, and let the # exception propagate. - try: - ScopedSession.rollback() - except Exception: - log.exception('CommitSessionMiddleware: rollback failed after downstream error') - finally: - ScopedSession.remove() + if ScopedSession.registry.has(): + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after downstream error') + finally: + ScopedSession.remove() raise + # Nothing in this request touched the sync session: committing would + # only instantiate one to run an empty transaction. + if not ScopedSession.registry.has(): + return + # Downstream completed. Commit pending sync work. try: ScopedSession.commit() @@ -166,9 +172,9 @@ class AuthTokenMiddleware: async def send_with_timing(message: Message) -> None: if message['type'] == 'http.response.start': - process_time = int(time.monotonic() - start_time) + process_time = time.monotonic() - start_time headers = MutableHeaders(scope=message) - headers['X-Process-Time'] = str(process_time) + headers['X-Process-Time'] = f'{process_time:.6f}' await send(message) await self.app(scope, receive, send_with_timing) diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 313f4cf235..890960bc58 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -132,14 +132,27 @@ class AuditLoggingMiddleware: ) -> None: self.app = app self.audit_logger = AuditLogger(logger) - self.excluded_paths = excluded_paths or [] - self.included_paths = included_paths or [] + + def normalize_paths(paths: Optional[list[str]]) -> list[str]: + return [path for path in (path.strip().lstrip('/') for path in paths or []) if path] + + self.excluded_paths = normalize_paths(excluded_paths) + self.included_paths = normalize_paths(included_paths) self.max_body_size = max_body_size self.audited_methods = set(self.DEFAULT_AUDITED_METHODS) if audit_get_requests: self.audited_methods.add('GET') self.audit_level = audit_level + # Paths are fixed for the process lifetime; compile once instead of + # per request. None means the corresponding mode has nothing to match. + self._included_pattern = ( + re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None + ) + self._excluded_pattern = ( + re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None + ) + if self.included_paths and self.excluded_paths: logger.warning( 'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. ' @@ -196,6 +209,13 @@ class AuditLoggingMiddleware: await self._log_audit_entry(request, context) async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]: + # get_current_user stashes the resolved user on the scope-backed state; + # reuse it instead of running the full auth pipeline (JWT decode, Redis + # revocation checks, DB fetch, last-active write) a second time. + user = getattr(request.state, 'user', None) + if isinstance(user, UserModel): + return user + auth_header = request.headers.get('Authorization') try: @@ -206,6 +226,12 @@ class AuditLoggingMiddleware: return None + ALWAYS_LOG_ENDPOINTS = ( + '/api/v1/auths/signin', + '/api/v1/auths/signout', + '/api/v1/auths/signup', + ) + def _should_skip_auditing(self, request: Request) -> bool: if AUDIT_LOG_LEVEL == 'NONE': return True @@ -213,13 +239,8 @@ class AuditLoggingMiddleware: if request.method not in self.audited_methods: return True - ALWAYS_LOG_ENDPOINTS = { - '/api/v1/auths/signin', - '/api/v1/auths/signout', - '/api/v1/auths/signup', - } path = request.url.path.lower() - for endpoint in ALWAYS_LOG_ENDPOINTS: + for endpoint in self.ALWAYS_LOG_ENDPOINTS: if path.startswith(endpoint): return False # Do NOT skip logging for auth endpoints @@ -229,15 +250,11 @@ class AuditLoggingMiddleware: return True # Whitelist mode: only log paths that match included_paths - if self.included_paths: - pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') - if not pattern.match(request.url.path): - return True # Skip: path not in whitelist - return False # Do NOT skip: path is in whitelist + if self._included_pattern: + return not self._included_pattern.match(request.url.path) # Blacklist mode: skip paths that match excluded_paths - pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') - if pattern.match(request.url.path): + if self._excluded_pattern and self._excluded_pattern.match(request.url.path): return True return False diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 42b1aea9bd..f9d596e615 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -356,6 +356,8 @@ async def get_current_user( current_span.set_attribute('client.user.role', user.role) current_span.set_attribute('client.auth.type', 'api_key') + # Scope-backed, so outer middleware (audit) can reuse the resolved user + request.state.user = user return user # auth by jwt token @@ -404,6 +406,9 @@ async def get_current_user( # Refresh the user's last active timestamp # Fire-and-forget via asyncio.create_task to avoid blocking asyncio.create_task(Users.update_last_active_by_id(user.id)) + + # Scope-backed, so outer middleware (audit) can reuse the resolved user + request.state.user = user return user else: raise HTTPException( diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 196689f61d..2850c92b93 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -179,8 +179,10 @@ async def generate_chat_completion( # Merge the direct connection model into server models so that # task functions (title, tags, etc.) can resolve a server-side # task model while still having the direct model available. + # dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}`` + # would issue HKEYS plus one HGET per model. models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } log.debug(f'direct connection to model: {request.state.model["id"]}') @@ -188,11 +190,12 @@ async def generate_chat_completion( models = request.app.state.MODELS model_id = form_data['model'] - if model_id not in models: + # Single lookup — membership check plus getitem would be two Redis + # round trips on a Redis-backed model pool. + model = models.get(model_id) + if model is None: raise Exception('Model not found') - model = models[model_id] - if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'): return await generate_direct_chat_completion(request, form_data, user=user, models=models) else: diff --git a/backend/open_webui/utils/chat_variables.py b/backend/open_webui/utils/chat_variables.py new file mode 100644 index 0000000000..37438254aa --- /dev/null +++ b/backend/open_webui/utils/chat_variables.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$') +CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') +USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') +MAX_VARIABLE_VALUE_LENGTH = 20_000 +MAX_VARIABLES_JSON_LENGTH = 100_000 + + +class ChatVariablesError(ValueError): + pass + + +def split_properties(value: str, delimiter: str) -> list[str]: + result: list[str] = [] + current = '' + depth = 0 + in_string = False + escape_next = False + + for char in value: + if escape_next: + current += char + escape_next = False + continue + + if char == '\\': + current += char + escape_next = True + continue + + if char == '"' and not escape_next: + in_string = not in_string + current += char + continue + + if not in_string: + if char in ('{', '['): + depth += 1 + elif char in ('}', ']'): + depth -= 1 + + if char == delimiter and depth == 0: + result.append(current.strip()) + current = '' + continue + + current += char + + if current.strip(): + result.append(current.strip()) + + return result + + +def parse_json_value(value: str) -> Any: + if value.startswith('"') and value.endswith('"'): + return value[1:-1] + + if re.match(r'^[\[{]', value): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + return value + + +def parse_variable_definition(definition: str) -> dict[str, Any]: + parts = split_properties(definition, ':') + if not parts: + return {'type': 'text'} + + first_part, *property_parts = parts + field_type = first_part[5:] if first_part.startswith('type=') else first_part + field_type = field_type.strip() or 'text' + properties: dict[str, Any] = {} + + for part in property_parts: + trimmed = part.strip() + if not trimmed: + continue + + equals_parts = split_properties(trimmed, '=') + if len(equals_parts) == 1: + properties[equals_parts[0].strip()] = True + continue + + property_name, *value_parts = equals_parts + properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip()) + + return {'type': field_type, **properties} + + +def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]: + allowed_keys = { + 'default', + 'label', + 'max', + 'maxlength', + 'min', + 'minlength', + 'options', + 'placeholder', + 'required', + 'step', + 'type', + } + field = {'key': key} + for field_key in allowed_keys: + if field_key in definition: + field[field_key] = definition[field_key] + + field.setdefault('type', 'text') + if field.get('type') == 'select' and not isinstance(field.get('options'), list): + field['options'] = [] + field['required'] = bool(field.get('required', False)) + + return field + + +def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None: + if not system_prompt: + return None + + try: + fields_by_key = collect_chat_variable_fields(system_prompt) + except ChatVariablesError: + fields_by_key = {} + + if not fields_by_key: + return None + + return {'fields': list(fields_by_key.values())} + + +def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]: + fields_by_key: dict[str, dict[str, Any]] = {} + if not system_prompt: + return fields_by_key + + typed_fields_by_key: dict[str, dict[str, Any]] = {} + for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt): + key = match.group(1).strip() + definition = match.group(2) + if not CHAT_VARIABLE_KEY_RE.match(key): + raise ChatVariablesError(f'Invalid chat variable key: {key}') + + if definition is None or not definition.strip(): + fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'})) + continue + + field = _safe_field(key, parse_variable_definition(definition.strip())) + + if field.get('type') == 'select' and not field.get('options'): + raise ChatVariablesError(f'Chat variable {key} select needs options.') + previous = typed_fields_by_key.get(key) + if previous and previous != field: + raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.') + typed_fields_by_key[key] = field + fields_by_key[key] = field + + return fields_by_key + + +def normalize_chat_variables(variables: Any) -> dict[str, Any]: + if not isinstance(variables, dict): + return {} + return variables + + +def normalize_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + return {} + return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)} + + +def validate_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + raise ChatVariablesError('User variables must be an object.') + + try: + if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + raise ChatVariablesError('User variables are too large.') + except TypeError: + raise ChatVariablesError('User variables must be JSON serializable.') + + validated: dict[str, str] = {} + for key, value in variables.items(): + if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key): + raise ChatVariablesError(f'Invalid user variable key: {key}') + if not isinstance(value, str): + raise ChatVariablesError(f'User variable must be a string: {key}') + value = value.replace('\r\n', '\n') + if len(value) > MAX_VARIABLE_VALUE_LENGTH: + raise ChatVariablesError(f'User variable is too long: {key}') + validated[key] = value + + return validated + + +def validate_chat_variables( + system_prompt: str | None, + variables: Any, + *, + required: bool = True, +) -> dict[str, Any]: + field_map = collect_chat_variable_fields(system_prompt) + variables = normalize_chat_variables(variables) + + try: + if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + raise ChatVariablesError('Chat variables are too large.') + except TypeError: + raise ChatVariablesError('Chat variables must be JSON serializable.') + + validated: dict[str, Any] = {} + for key, field in field_map.items(): + has_value = key in variables and variables[key] not in (None, '') + value = variables.get(key) + + if not has_value: + if field.get('default') not in (None, ''): + value = field.get('default') + has_value = True + elif required and field.get('required'): + label = field.get('label') or key + raise ChatVariablesError(f'Missing required chat variable: {label}') + else: + value = '' + + if field.get('type') == 'select': + options = field.get('options') or [] + if has_value and value not in options: + label = field.get('label') or key + raise ChatVariablesError(f'Invalid value for chat variable: {label}') + + if isinstance(value, str): + value = value.replace('\r\n', '\n') + if len(value) > MAX_VARIABLE_VALUE_LENGTH: + label = field.get('label') or key + raise ChatVariablesError(f'Chat variable is too long: {label}') + + validated[key] = value + + return validated + + +def render_chat_variables( + system_prompt: str | None, + variables: Any, + *, + required: bool = True, +) -> str | None: + if not system_prompt: + return system_prompt + + try: + validated = validate_chat_variables(system_prompt, variables, required=required) + except ChatVariablesError: + validated = {} + + def replace(match: re.Match) -> str: + key = match.group(1).strip() + value = validated.get(key, '') + return '' if value is None else str(value) + + return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt) + + +def render_user_variables(system_prompt: str | None, variables: Any) -> str | None: + if not system_prompt: + return system_prompt + + variables = normalize_user_variables(variables) + + def replace(match: re.Match) -> str: + key = match.group(1).strip() + if not CHAT_VARIABLE_KEY_RE.match(key): + return '' + return variables.get(key, '') + + return USER_VARIABLE_ANY_RE.sub(replace, system_prompt) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 910fa5b7b6..1a9b5e9f96 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -56,7 +56,7 @@ async def compact_messages_for_request( if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3: return messages, previous_summary, False - boundary = _find_compaction_boundary(messages) + boundary = _find_compaction_boundary(messages, config['retention_percentage']) compacted_messages = messages[:boundary] recent_messages = messages[boundary:] if not compacted_messages or not recent_messages: @@ -196,6 +196,7 @@ async def _load_config() -> dict: 'chat.context_compaction.enable', 'chat.context_compaction.token_threshold', 'chat.context_compaction.token_cap', + 'chat.context_compaction.retention_percentage', 'chat.context_compaction.prompt_template', ) token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000 @@ -203,6 +204,9 @@ async def _load_config() -> dict: 'enable': bool(values.get('chat.context_compaction.enable', False)), 'token_threshold': token_threshold, 'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold, + 'retention_percentage': _clamp_retention_percentage( + values.get('chat.context_compaction.retention_percentage') + ), 'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '', } @@ -215,6 +219,14 @@ def _parse_positive_int(value: Any) -> int | None: return parsed if parsed > 0 else None +def _clamp_retention_percentage(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = 40 + return min(50, max(10, parsed)) + + def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int: configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold')) return min(configured_threshold or global_threshold, global_cap) @@ -297,8 +309,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: return estimated > threshold -def _find_compaction_boundary(messages: list[dict]) -> int: - keep_count = max(2, len(messages) * 2 // 5) +def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int: + retention_percentage = _clamp_retention_percentage(retention_percentage) + keep_count = max(2, len(messages) * retention_percentage // 100) target = max(1, len(messages) - keep_count) boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:] return next((idx for idx in reversed(boundaries) if idx <= target), 0) diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index e43057c5d8..74527a40b6 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -6,6 +6,7 @@ import re from pathlib import Path from typing import Optional +import aiofiles from fastapi import ( APIRouter, Depends, @@ -75,7 +76,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]: # file-ID resolver which enforces ownership/access checks. return await get_image_base64_from_file_id(url, user=user) - except Exception as e: + except Exception: return None @@ -200,15 +201,15 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]: # Check if the file already exists in the cache if file_path.is_file(): - with open(file_path, 'rb') as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') - content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') - if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: - content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) - if not content_type: - return None - return f'data:{content_type};base64,{encoded_string}' + async with aiofiles.open(file_path, 'rb') as image_file: + encoded_string = base64.b64encode(await image_file.read()).decode('utf-8') + content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') + if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: + content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) + if not content_type: + return None + return f'data:{content_type};base64,{encoded_string}' else: return None - except Exception as e: + except Exception: return None diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 1b5c683aa6..994aeec5d1 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -57,6 +57,7 @@ from open_webui.routers.images import ( image_generations, ) from open_webui.routers.pipelines import ( + get_sorted_filters, process_pipeline_inlet_filter, process_pipeline_outlet_filter, ) @@ -106,6 +107,7 @@ from open_webui.utils.misc import ( get_last_user_message, get_last_user_message_item, get_message_list, + get_output_text, get_system_message, is_string_allowed, merge_system_messages, @@ -136,17 +138,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -def _last_output_text(output: list | None) -> str: - for item in reversed(output or []): - if item.get('type') != 'message': - continue - parts = item.get('content') or [] - text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text') - if text: - return text - return '' - - async def publish_chat_finished_event( request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None ): @@ -154,7 +145,7 @@ async def publish_chat_finished_event( if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')): return - content = content or _last_output_text(output) + content = content or get_output_text(output) webui_url = await Config.get('webui.url') await publish_event( request, @@ -2162,21 +2153,20 @@ SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]* def strip_skill_mentions(messages: list[dict]) -> None: """Replace <$skillId|label> and mention tags with the label in-place.""" - strip_re = SKILL_MENTION_STRIP_RE def label(match): return match.group(1) or match.group(2) or '' for message in messages: content = message.get('content') - if isinstance(content, str) and strip_re.search(content): - message['content'] = strip_re.sub(label, content).strip() + if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content): + message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content).strip() elif isinstance(content, list): for part in content: if isinstance(part, dict) and part.get('type') == 'text': text = part.get('text', '') - if strip_re.search(text): - part['text'] = strip_re.sub(label, text).strip() + if SKILL_MENTION_STRIP_RE.search(text): + part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text).strip() async def connect_mcp_server( @@ -2575,7 +2565,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Mentioned skills get full content; selected/default skills can be loaded through view_skill. mentioned_skill_ids = extract_skill_ids_from_messages(form_data.get('messages', [])) - skill_ids = ( + skill_ids = sorted( set(form_data.pop('skill_ids', None) or []) | set(model.get('info', {}).get('meta', {}).get('skillIds', [])) | mentioned_skill_ids @@ -2678,7 +2668,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): { 'model_id': form_data.get('model'), 'tool_ids': tool_ids, - 'skill_ids': list(skill_ids), + 'skill_ids': skill_ids, 'terminal_id': terminal_id, 'files': files, 'features': features, @@ -3398,7 +3388,7 @@ async def outlet_filter_handler(ctx): message_list = [ { 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), } for m in form_messages ] @@ -3431,7 +3421,7 @@ async def outlet_filter_handler(ctx): { 'id': m.get('id'), 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), 'info': m.get('info'), 'timestamp': m.get('timestamp'), **({'output': m['output']} if m.get('output') else {}), @@ -3483,17 +3473,21 @@ async def outlet_filter_handler(ctx): outlet_message_id = message.get('id') if outlet_message_id and outlet_message_id in messages_map: original_message = messages_map[outlet_message_id] - content_changed = original_message.get('content') != message.get('content') + original_content = original_message.get('content') or get_output_text( + original_message.get('output') + ) + message_content = message.get('content') or get_output_text(message.get('output')) + content_changed = original_content != message_content output_changed = message.get('output') and message.get('output') != original_message.get( 'output' ) if content_changed or output_changed: message_update = { - 'originalContent': original_message.get('content'), + 'originalContent': original_content, **({'output': message['output']} if output_changed else {}), } if content_changed: - message_update['content'] = message.get('content', '') + message_update['content'] = message_content or '' await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, outlet_message_id, @@ -3975,9 +3969,10 @@ async def streaming_chat_response_handler(response, ctx): except Exception as e: pass - content = ( + initial_content = ( message.get('content', '') if message else last_assistant_message if last_assistant_message else '' ) + content_parts = [initial_content] if initial_content else [] # Initialize output: use existing from message if continuing, else create new existing_output = message.get('output') if message else None @@ -3985,14 +3980,14 @@ async def streaming_chat_response_handler(response, ctx): output = existing_output else: # Only create an initial message item if there is content to initialize with - if content: + if initial_content: output = [ { 'type': 'message', 'id': output_id('msg'), 'status': 'in_progress', 'role': 'assistant', - 'content': [{'type': 'output_text', 'text': content}], + 'content': [{'type': 'output_text', 'text': initial_content}], } ] else: @@ -4054,7 +4049,7 @@ async def streaming_chat_response_handler(response, ctx): ) async def stream_body_handler(response, form_data): - nonlocal content + nonlocal content_parts nonlocal usage nonlocal output nonlocal prior_output @@ -4119,7 +4114,7 @@ async def streaming_chat_response_handler(response, ctx): raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None if raw_error: try: - Chats.upsert_message_to_chat_by_id_and_message_id( + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { @@ -4505,12 +4500,8 @@ async def streaming_chat_response_handler(response, ctx): user, ) - if isinstance(value, str): - # In-place append — avoids copying the full - # accumulated response on every chunk. - content += value - else: - content = f'{content}{value}' + # closure-cell str += recopies per chunk; append + join once at read is O(n) + content_parts.append(value if isinstance(value, str) else f'{value}') # Check if we're inside a tag-based block # (reasoning, code_interpreter, or solution). @@ -5377,7 +5368,7 @@ async def streaming_chat_response_handler(response, ctx): {'done': True}, ) - await publish_chat_finished_event(request, user, metadata, title, content, output) + await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output) await event_emitter( { @@ -5443,6 +5434,16 @@ async def streaming_chat_response_handler(response, ctx): return f'data: {item}\n\n' assistant_message = {} + has_api_outlet_filters = ENABLE_API_OUTLET_FILTERS and bool(filter_functions) + if ENABLE_API_OUTLET_FILTERS and not has_api_outlet_filters: + try: + model_id = model.get('id') if isinstance(model, dict) else model + has_api_outlet_filters = bool( + (isinstance(model, dict) and 'pipeline' in model) + or get_sorted_filters(model_id, request.app.state.MODELS) + ) + except Exception: + has_api_outlet_filters = True for event in events: event, _ = await process_filter_functions( @@ -5466,11 +5467,11 @@ async def streaming_chat_response_handler(response, ctx): ) if data: - if ENABLE_API_OUTLET_FILTERS: + if has_api_outlet_filters: update_assistant_message_from_stream(assistant_message, data) yield data - if ENABLE_API_OUTLET_FILTERS and assistant_message: + if has_api_outlet_filters and assistant_message: ctx['assistant_message'] = assistant_message await outlet_filter_handler(ctx) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 509663fcfb..cff3ad6e40 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -17,6 +17,7 @@ import mimeparse from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE log = logging.getLogger(__name__) +SURROGATE_RE = re.compile('[\ud800-\udfff]') def deep_update(d, u): @@ -28,18 +29,26 @@ def deep_update(d, u): return d +def _strip_filter_entry(entry): + # Compose list-form env syntax passes surrounding quotes through verbatim + return (entry or '').strip().strip('"\'').strip() + + def get_allow_block_lists(filter_list): allow_list = [] block_list = [] - if filter_list: - for d in filter_list: - if d.startswith('!'): - # Domains starting with "!" → blocked - block_list.append(d[1:].strip()) - else: - # Domains starting without "!" → allowed - allow_list.append(d.strip()) + for raw_entry in filter_list or []: + entry = _strip_filter_entry(raw_entry) + is_blocked = entry.startswith('!') + if is_blocked: + entry = _strip_filter_entry(entry[1:]) + if not entry: + continue + if is_blocked: + block_list.append(entry) + else: + allow_list.append(entry) return allow_list, block_list @@ -159,13 +168,40 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None: def get_content_from_message(message: dict) -> str | None: - if isinstance(message.get('content'), list): - for item in message['content']: - if item['type'] == 'text': - return item['text'] - else: - return message.get('content') - return None + content = message.get('content') + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'text': + return item.get('text') + elif content: + return content + + output_text = get_output_text(message.get('output')) + return output_text or (content if isinstance(content, str) else None) + + +def get_output_text(output: list | None) -> str: + if not isinstance(output, list): + return '' + + texts = [] + for item in output: + if not isinstance(item, dict) or item.get('type') != 'message': + continue + + parts = item.get('content') or [] + if not isinstance(parts, list): + continue + + text = ''.join( + str(part.get('text')) + for part in parts + if isinstance(part, dict) and part.get('text') is not None + ) + if text.strip(): + texts.append(text) + + return '\n'.join(texts) def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: @@ -212,7 +248,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: # All tool_calls were orphans — keep the message only if it # carries meaningful text or reasoning content. - content = message.get('content', '') + content = get_content_from_message(message) or '' has_meaningful_content = content.strip() if isinstance(content, str) else content if has_meaningful_content or message.get('reasoning_content'): reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'}) @@ -715,18 +751,10 @@ def sanitize_text_for_db(text: str) -> str: """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" if not isinstance(text, str): return text - # Fast path: skip work when there are no null bytes (the common case) - if '\x00' not in text: + # Fast path: skip work when there are no null bytes or surrogate code points. + if '\x00' not in text and not SURROGATE_RE.search(text): return text - # Remove null bytes - text = text.replace('\x00', '').replace('\u0000', '') - # Remove invalid UTF-8 surrogate characters that can cause encoding errors - # This handles cases where binary data or encoding issues introduced surrogates - try: - text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore') - except (UnicodeEncodeError, UnicodeDecodeError): - pass - return text + return SURROGATE_RE.sub('', text.replace('\x00', '')) def _strip_null_bytes_deep(obj): @@ -734,7 +762,10 @@ def _strip_null_bytes_deep(obj): if isinstance(obj, str): return sanitize_text_for_db(obj) elif isinstance(obj, dict): - return {k: _strip_null_bytes_deep(v) for k, v in obj.items()} + cleaned = {} + for k, v in obj.items(): + cleaned[sanitize_text_for_db(k) if isinstance(k, str) else k] = _strip_null_bytes_deep(v) + return cleaned elif isinstance(obj, list): return [_strip_null_bytes_deep(v) for v in obj] return obj @@ -744,19 +775,21 @@ def sanitize_data_for_db(obj): """Recursively sanitize all strings in a data structure for database storage. Performs a fast pre-check: serializes the structure once and scans for - null bytes. If none are found (the overwhelmingly common case), the + null bytes or invalid UTF-8 surrogates. If none are found, the original object is returned immediately, skipping the expensive recursive walk. """ if isinstance(obj, str): return sanitize_text_for_db(obj) - # Fast path: check for null bytes in the serialized form. + # Fast path: check for null bytes and surrogate code points in the serialized form. # json.dumps is implemented in C and much faster than a Python-level # recursive walk over every leaf string. try: - if '\\u0000' not in json.dumps(obj, ensure_ascii=False): + serialized = json.dumps(obj, ensure_ascii=False) + if '\\u0000' not in serialized: + serialized.encode('utf-8') return obj - except (TypeError, ValueError): + except (TypeError, ValueError, UnicodeEncodeError): pass return _strip_null_bytes_deep(obj) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 9421975dcf..a5b2261b14 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -16,6 +16,7 @@ from open_webui.models.config import Config from open_webui.models.functions import Functions from open_webui.models.groups import Groups from open_webui.models.models import Models +from open_webui.utils.chat_variables import get_chat_variables_schema from open_webui.models.users import UserModel from open_webui.routers import ollama, openai from open_webui.socket.utils import RedisDict @@ -129,13 +130,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) # 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} + active_actions = await Functions.get_active_function_ids_by_type('action') + global_action_ids = {function_id for function_id, is_global in active_actions if is_global} + enabled_action_ids = {function_id for function_id, _ in active_actions} - 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} + active_filters = await Functions.get_active_function_ids_by_type('filter') + global_filter_ids = {function_id for function_id, is_global in active_filters if is_global} + enabled_filter_ids = {function_id for function_id, _ in active_filters} else: global_action_ids = set() enabled_action_ids = set() @@ -162,6 +163,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if custom_model.is_active: model['name'] = custom_model.name model['info'] = custom_model.model_dump() + schema = get_chat_variables_schema(custom_model.params.model_dump().get('system')) + if schema: + model['info'].setdefault('meta', {})['chat_variables_schema'] = schema action_ids = [] filter_ids = [] @@ -211,6 +215,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) } info = custom_model.model_dump() + schema = get_chat_variables_schema(custom_model.params.model_dump().get('system')) + if schema: + info.setdefault('meta', {})['chat_variables_schema'] = schema if 'params' in info: # Remove params to avoid exposing sensitive info del info['params'] @@ -328,16 +335,27 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids)) functions_cache = get_functions_cache(request) + # Global actions and filters appear in every model, so priorities and item + # lists are memoized across the loop instead of rebuilt per model. + action_priorities = {} + def get_action_priority(action_id): + if action_id in action_priorities: + return action_priorities[action_id] + priority = 0 try: function_module = functions_cache.get(action_id) if function_module and hasattr(function_module, 'Valves'): valves_db = all_function_valves.get(action_id) valves = function_module.Valves(**(valves_db if valves_db else {})) - return getattr(valves, 'priority', 0) + priority = getattr(valves, 'priority', 0) except Exception: - pass - return 0 + priority = 0 + action_priorities[action_id] = priority + return priority + + action_items_by_id = {} + filter_items_by_id = {} for model in models: action_ids = [ @@ -355,30 +373,45 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) model['actions'] = [] for action_id in action_ids: - action_function = functions_by_id.get(action_id) - if action_function is None: - log.info(f'Action not found: {action_id}') - continue + items = action_items_by_id.get(action_id) + if items is None: + action_function = functions_by_id.get(action_id) + if action_function is None: + log.info(f'Action not found: {action_id}') + action_items_by_id[action_id] = [] + continue - function_module = functions_cache.get(action_id) - if function_module is None: - log.info(f'Failed to load action module: {action_id}') - continue - model['actions'].extend(get_action_items_from_module(action_function, function_module)) + function_module = functions_cache.get(action_id) + if function_module is None: + log.info(f'Failed to load action module: {action_id}') + action_items_by_id[action_id] = [] + continue + items = get_action_items_from_module(action_function, function_module) + action_items_by_id[action_id] = items + # Shallow copies keep per-model item dicts independent, as before + model['actions'].extend({**item} for item in items) model['filters'] = [] for filter_id in filter_ids: - filter_function = functions_by_id.get(filter_id) - if filter_function is None: - log.info(f'Filter not found: {filter_id}') - continue + items = filter_items_by_id.get(filter_id) + if items is None: + filter_function = functions_by_id.get(filter_id) + if filter_function is None: + log.info(f'Filter not found: {filter_id}') + filter_items_by_id[filter_id] = [] + continue - function_module = functions_cache.get(filter_id) - if function_module is None: - log.info(f'Failed to load filter module: {filter_id}') - continue - if getattr(function_module, 'toggle', None): - model['filters'].extend(get_filter_items_from_module(filter_function, function_module)) + function_module = functions_cache.get(filter_id) + if function_module is None: + log.info(f'Failed to load filter module: {filter_id}') + filter_items_by_id[filter_id] = [] + continue + if getattr(function_module, 'toggle', None): + items = get_filter_items_from_module(filter_function, function_module) + else: + items = [] + filter_items_by_id[filter_id] = items + model['filters'].extend({**item} for item in items) log.debug(f'get_all_models() returned {len(models)} models') @@ -423,7 +456,7 @@ async def check_model_access(user, model, db=None): raise Exception('Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, db=db): + if not await has_base_model_access(user.id, model_info, user_role=user.role, db=db): raise Exception('Model not found') diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 0d72a24efe..63c72eeac3 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -16,7 +16,6 @@ from typing import Literal, Optional import aiohttp from authlib.integrations.starlette_client import OAuth -from authlib.jose.errors import BadSignatureError from authlib.oauth2.rfc6749.errors import OAuth2Error from authlib.oidc.core import UserInfo from cryptography.fernet import Fernet @@ -24,6 +23,7 @@ from fastapi import ( HTTPException, status, ) +from joserfc.errors import BadSignatureError from joserfc.jws import JWSRegistry from joserfc.registry import HeaderParameter from mcp.shared.auth import ( diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index d6242261db..89568c3a63 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -7,6 +7,7 @@ from open_webui.utils.misc import ( deep_update, replace_system_message_content, ) +from open_webui.utils.chat_variables import render_chat_variables, render_user_variables from open_webui.utils.task import prompt_template, prompt_variables_template @@ -18,6 +19,15 @@ async def resolve_system_prompt( if not system: return '' + if metadata: + system = render_chat_variables( + system, + metadata.get('chat_variables', {}), + required=False, + ) + + system = render_user_variables(system, getattr(user, 'variables', {}) if user else {}) + # Metadata (WebUI Usage) if metadata: variables = metadata.get('variables', {}) diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index 1417be134c..bee7f633b2 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -14,7 +14,6 @@ from typing import Any from urllib.parse import ParseResult, urlparse import redis as _redis_sync - from open_webui.env import ( REDIS_CLUSTER, REDIS_HEALTH_CHECK_INTERVAL, @@ -33,6 +32,7 @@ _ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'}) _SENTINEL_RETRYABLE = ( _redis_sync.exceptions.ConnectionError, _redis_sync.exceptions.ReadOnlyError, + _redis_sync.exceptions.TimeoutError, ) _FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'}) _CONNECTION_POOL: dict[tuple, Any] = {} @@ -126,10 +126,11 @@ class SentinelRedisProxy: self._sentinel = sentinel self._service_name = service_name self._async_mode = async_mode + self._master: Any | None = None def __getattr__(self, name: str) -> Any: """Proxy attribute access with automatic Sentinel failover retry.""" - current_master = self._sentinel.master_for(self._service_name) + current_master = self._resolve_master() original = getattr(current_master, name) # Non-callable or factory attributes pass through without wrapping. @@ -143,7 +144,12 @@ class SentinelRedisProxy: def _resolve_master(self) -> Any: """Ask Sentinel for the current master connection.""" - return self._sentinel.master_for(self._service_name) + if self._master is None: + self._master = self._sentinel.master_for(self._service_name) + return self._master + + def _clear_master(self) -> None: + self._master = None def _should_retry(self, attempt: int) -> bool: return attempt < REDIS_SENTINEL_MAX_RETRY_COUNT - 1 @@ -184,6 +190,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000) continue @@ -208,6 +215,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000) continue @@ -229,6 +237,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: time.sleep(REDIS_RECONNECT_DELAY / 1000) continue diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 7ccc3f7261..9d54afe476 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -4,7 +4,7 @@ fastapi==0.136.3 uvicorn[standard]==0.41.0 pydantic==2.13.4 -python-multipart==0.0.27 +python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 @@ -14,6 +14,7 @@ bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.13.0 authlib==1.7.2 +joserfc==1.7.4 requests==2.34.2 aiohttp==3.13.5 # do not update to 3.13.3 - broken diff --git a/backend/requirements.txt b/backend/requirements.txt index 4a3169762f..3885b6d843 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.136.3 uvicorn[standard]==0.41.0 pydantic==2.13.4 -python-multipart==0.0.27 +python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 @@ -11,6 +11,7 @@ bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.13.0 authlib==1.7.2 +joserfc==1.7.4 requests==2.34.2 aiohttp==3.13.5 # do not update to 3.13.3 - broken @@ -89,7 +90,7 @@ soundfile==0.13.1 pillow==12.2.0 opencv-python-headless==4.13.0.92 -rapidocr-onnxruntime==1.4.4 +rapidocr==3.9.2 rank-bm25==0.2.2 onnxruntime==1.26.0 diff --git a/pyproject.toml b/pyproject.toml index 1944904e53..c4790af4dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi==0.136.3", "uvicorn[standard]==0.41.0", "pydantic==2.13.4", - "python-multipart==0.0.27", + "python-multipart==0.0.32", "itsdangerous==2.2.0", "python-socketio==5.16.2", @@ -19,6 +19,7 @@ dependencies = [ "argon2-cffi==25.1.0", "PyJWT[crypto]==2.13.0", "authlib==1.7.2", + "joserfc==1.7.4", "requests==2.34.2", "aiohttp==3.13.5", # do not update to 3.13.3 - broken @@ -97,7 +98,7 @@ dependencies = [ "pillow==12.2.0", "opencv-python-headless==4.13.0.92", - "rapidocr-onnxruntime==1.4.4", + "rapidocr==3.9.2", "rank-bm25==0.2.2", "onnxruntime==1.26.0", diff --git a/src/app.css b/src/app.css index 1849577a8d..04628d7a8e 100644 --- a/src/app.css +++ b/src/app.css @@ -6,18 +6,6 @@ font-display: swap; } -@font-face { - font-family: 'Mona Sans'; - src: url('/assets/fonts/Mona-Sans.woff2'); - font-display: swap; -} - -@font-face { - font-family: 'InstrumentSerif'; - src: url('/assets/fonts/InstrumentSerif-Regular.ttf'); - font-display: swap; -} - @font-face { font-family: 'Vazirmatn'; src: url('/assets/fonts/Vazirmatn-Variable.ttf'); @@ -64,10 +52,6 @@ code { border-radius: 2px; } -.font-secondary { - font-family: 'InstrumentSerif', sans-serif; -} - .app-muted { @apply text-gray-500 dark:text-gray-400; } diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 64d2960dbf..0804a0f502 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -58,7 +58,12 @@ export const updateChatConfig = async (token: string, config: object) => { return res; }; -export const createNewChat = async (token: string, chat: object, folderId: string | null) => { +export const createNewChat = async ( + token: string, + chat: object, + folderId: string | null, + variables: object | null = null +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, { @@ -70,6 +75,7 @@ export const createNewChat = async (token: string, chat: object, folderId: strin }, body: JSON.stringify({ chat: chat, + ...(variables !== null ? { variables } : {}), folder_id: folderId ?? null }) }) @@ -1197,7 +1203,12 @@ export const getChatAccessGrants = async (token: string, id: string) => { return res; }; -export const updateChatById = async (token: string, id: string, chat: object) => { +export const updateChatById = async ( + token: string, + id: string, + chat: object, + variables: object | null = null +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, { @@ -1208,7 +1219,8 @@ export const updateChatById = async (token: string, id: string, chat: object) => ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify({ - chat: chat + chat: chat, + ...(variables !== null ? { variables } : {}) }) }) .then(async (res) => { diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 70bd660e9a..4f55ab14be 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -163,7 +163,14 @@ export const getModels = async ( // Remove duplicates const modelsMap = {}; for (const model of models) { - modelsMap[model.id] = model; + const existing = modelsMap[model.id]; + modelsMap[model.id] = existing + ? { + ...existing, + ...model, + info: existing.info ?? model.info + } + : model; } models = Object.values(modelsMap); diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 95c0ebac18..82556e7822 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -440,6 +440,62 @@ export const updateUserInfo = async (token: string, info: object) => { return res; }; +export const getUserVariables = async (token: string) => { + let error = null; + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateUserVariables = async (token: string, variables: Record) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + variables + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAndUpdateUserLocation = async (token: string) => { const location = await getUserPosition().catch((err) => { console.error(err); diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 5d4083ff54..3f2c78b426 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -251,14 +251,14 @@ type="date" bind:value={customStart} max={customEnd || undefined} - class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none" + class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none dark:scheme-dark" /> – {/if} + + -
+
{ selectedModelId = model.id; diff --git a/src/lib/components/automations/ScheduleDropdown.svelte b/src/lib/components/automations/ScheduleDropdown.svelte index 44d1a4db4d..14dc3015f2 100644 --- a/src/lib/components/automations/ScheduleDropdown.svelte +++ b/src/lib/components/automations/ScheduleDropdown.svelte @@ -230,7 +230,7 @@ type="date" bind:value={onceDate} min={new Date().toISOString().split('T')[0]} - class="bg-transparent outline-hidden text-xs dark:color-scheme-dark" + class="bg-transparent outline-hidden text-xs dark:scheme-dark" on:click={(e) => e.stopPropagation()} on:input={onChange} /> @@ -239,7 +239,7 @@ e.stopPropagation()} on:input={onChange} /> @@ -256,7 +256,7 @@ minute = m; onChange(); }} - class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark" + class="bg-transparent text-center outline-hidden text-xs dark:scheme-dark" on:click={(e) => e.stopPropagation()} />
diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index 902a9dcb08..50d96cdccd 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -214,11 +214,11 @@
{$i18n.t('When')}
- + {#if !allDay} - + – - + {/if}
+ {#if hasChatVariables} + + + + {/if} + {#if isActive && prompt === '' && files.length === 0}
diff --git a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte index b8b75da5d4..db45013ab8 100644 --- a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte +++ b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte @@ -14,11 +14,16 @@ export let show = false; export let variables = {}; + export let title = $i18n.t('Input Variables'); export let onSave = (e) => {}; let loading = true; let variableValues = {}; + let variablesKey = ''; + + const getVariableLabel = (variable) => variables[variable]?.label ?? variable; + const getVariablesKey = (value) => JSON.stringify(value ?? {}); const submitHandler = async () => { // Normalize Windows CRLF (\r\n) to LF (\n) for all string values @@ -59,15 +64,25 @@ } }; + $: if (!show) { + variablesKey = ''; + } + $: if (show) { - init(); + const key = getVariablesKey(variables); + if (key !== variablesKey) { + variablesKey = key; + init(); + } }
-
{$i18n.t('Input Variables')}
+
+ {title} +
-
+
- {variable} + {getVariableLabel(variable)} {#if variables[variable]?.required ?? false} - *{$i18n.t('required')} + * {$i18n.t('required')} {/if}
@@ -111,13 +126,11 @@ bind:value={variableValues[variable]} id="input-variable-{idx}" > - {#if variables[variable]?.placeholder} - - {/if} + {#each variables[variable]?.options ?? [] as option} - {/each} @@ -175,7 +188,7 @@ {:else if variables[variable]?.type === 'date'} { + if (speechRecognition) { + // detach onend so cancelling does not confirm the transcription + speechRecognition.onend = null; + } + await stopRecording(); + }; + const stopRecording = async () => { if (recording && mediaRecorder) { await mediaRecorder.stop(); @@ -411,7 +422,7 @@ const handleKeyDown = (e) => { if (e.key === 'Escape') { e.preventDefault(); - stopRecording(); + cancelRecording(); onCancel(); } }; @@ -468,7 +479,7 @@ rounded-full" on:click={async () => { - stopRecording(); + cancelRecording(); onCancel(); }} > diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 34f529f4e9..2f4ad5fec9 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -43,6 +43,7 @@ export let forkHandler: Function | null = null; export let readOnly = false; + export let allowDelete = true; export let compactPreview = false; export let editCodeBlock = true; @@ -89,7 +90,7 @@ let message = history.messages[history.currentId]; const visitedMessageIds = new Set(); - while (message && (messagesCount !== null ? _messages.length <= messagesCount : true)) { + while (message && (messagesCount !== null ? _messages.length < messagesCount : true)) { if (visitedMessageIds.has(message.id)) { console.warn('Circular dependency detected in message history', message.id); break; @@ -555,6 +556,7 @@ {mergeResponses} {addMessages} {forkHandler} + {allowDelete} {triggerScroll} {readOnly} {compactPreview} diff --git a/src/lib/components/chat/Messages/Message.svelte b/src/lib/components/chat/Messages/Message.svelte index 09700aabad..c899c9dccd 100644 --- a/src/lib/components/chat/Messages/Message.svelte +++ b/src/lib/components/chat/Messages/Message.svelte @@ -42,17 +42,22 @@ export let forkHandler: Function | null = null; export let triggerScroll; export let readOnly = false; + export let allowDelete = true; export let compactPreview = false; export let editCodeBlock = true; export let topPadding = false; export let onInsertToNote: ((content: string) => void) | null = null; + + // Safari's content-visibility implementation has paint bugs that leave + // on-screen messages blank (#26712), so skip virtualization there + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
{#if history.messages[messageId]} {#if history.messages[messageId].role === 'user'} @@ -72,6 +77,7 @@ {showNextMessage} {editMessage} {deleteMessage} + {allowDelete} {readOnly} {compactPreview} {editCodeBlock} @@ -97,6 +103,7 @@ {actionMessage} {submitMessage} {deleteMessage} + {allowDelete} {continueResponse} {regenerateResponse} {addMessages} @@ -122,6 +129,7 @@ {actionMessage} {submitMessage} {deleteMessage} + {allowDelete} {continueResponse} {regenerateResponse} {mergeResponses} diff --git a/src/lib/components/chat/Messages/MultiResponseMessages.svelte b/src/lib/components/chat/Messages/MultiResponseMessages.svelte index acd35fff86..4edc7d1c94 100644 --- a/src/lib/components/chat/Messages/MultiResponseMessages.svelte +++ b/src/lib/components/chat/Messages/MultiResponseMessages.svelte @@ -28,6 +28,7 @@ export let isLastMessage; export let readOnly = false; + export let allowDelete = true; export let compactPreview = false; export let editCodeBlock = true; @@ -377,6 +378,7 @@ {saveMessage} {rateMessage} {deleteMessage} + {allowDelete} {actionMessage} {submitMessage} {continueResponse} diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte index 9c56bc14c0..8dc76a683a 100644 --- a/src/lib/components/chat/Messages/RateComment.svelte +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -130,14 +130,14 @@
-
-
-
+
+
+
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating} - - {/if} - {#if onInsertToNote && visibleResponseContent} + + {/if} + {#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)} {#if $settings?.regenerateMenu ?? true} +
+
+ {#each variableRows as row, idx} +
+
+ {row.key || $i18n.t('key_name')} +
+
+ {row.value || $i18n.t('Empty')} +
+ +
+ {/each} + + {#if variableRows.length === 0} +
+ {$i18n.t('No user variables configured.')} +
+ {/if} +
+ +
+ {$i18n.t('Use these in model system prompts as {{example}}.', { + example: '{{user.variables.key_name}}' + })} +
+ + {#if $config?.features.enable_login_form && $config?.features.enable_password_change_form} @@ -397,3 +533,68 @@
+ + + +

+ {variableFormIndex === null ? $i18n.t('Add User Variable') : $i18n.t('Edit User Variable')} +

+ +
+ {$i18n.t('Key')} +
+ + +
+ {$i18n.t('Value')} +
+