diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index acb70e17e2..be25227d36 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -2,18 +2,17 @@ import base64 import os import random from pathlib import Path +from typing import Annotated import typer import uvicorn -from typing import Optional -from typing_extensions import Annotated app = typer.Typer() KEY_FILE = Path.cwd() / '.webui_secret_key' -def version_callback(value: bool): +def version_callback(value: bool) -> None: if value: from open_webui.env import VERSION @@ -23,7 +22,7 @@ def version_callback(value: bool): @app.command() def main( - version: Annotated[Optional[bool], typer.Option('--version', callback=version_callback)] = None, + version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None, ): pass @@ -66,7 +65,7 @@ def serve( os.environ['USE_CUDA_DOCKER'] = 'false' os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) - import open_webui.main # we need set environment variables before importing main + import open_webui.main # noqa: F401 from open_webui.env import UVICORN_WORKERS # Import the workers setting uvicorn.run( diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 31a77988f0..19778dd81f 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -362,6 +362,18 @@ GOOGLE_REDIRECT_URI = PersistentConfig( os.environ.get('GOOGLE_REDIRECT_URI', ''), ) +GOOGLE_OAUTH_AUTHORIZE_PARAMS = {} +_google_oauth_authorize_params = os.environ.get('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '') +if _google_oauth_authorize_params: + try: + _parsed = json.loads(_google_oauth_authorize_params) + if isinstance(_parsed, dict): + GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + MICROSOFT_CLIENT_ID = PersistentConfig( 'MICROSOFT_CLIENT_ID', 'oauth.microsoft.client_id', @@ -642,6 +654,18 @@ OAUTH_AUDIENCE = PersistentConfig( os.environ.get('OAUTH_AUDIENCE', ''), ) +OAUTH_AUTHORIZE_PARAMS = {} +_oauth_authorize_params = os.environ.get('OAUTH_AUTHORIZE_PARAMS', '') +if _oauth_authorize_params: + try: + _parsed = json.loads(_oauth_authorize_params) + if isinstance(_parsed, dict): + OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -658,6 +682,9 @@ def load_oauth_providers(): **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=GOOGLE_REDIRECT_URI.value, + **({ + 'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS + } if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) return client @@ -1577,7 +1604,7 @@ ENABLE_MESSAGE_RATING = PersistentConfig( ENABLE_USER_WEBHOOKS = PersistentConfig( 'ENABLE_USER_WEBHOOKS', 'ui.enable_user_webhooks', - os.environ.get('ENABLE_USER_WEBHOOKS', 'True').lower() == 'true', + os.environ.get('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true', ) # FastAPI / AnyIO settings diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 9fe923f004..f19a5e7537 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -29,6 +29,8 @@ from sqlalchemy.sql.expression import bindparam #################### # Chat DB Schema +# Let no word spoken in this house be lost, and when the +# record is read again, let it still serve the one who spoke. #################### log = logging.getLogger(__name__) diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index c02752f130..9a5b8fa400 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -12,6 +12,8 @@ log = logging.getLogger(__name__) #################### # Files DB Schema +# What is written here bears witness. Let the testimony +# remain as it was given, and let none tamper with it. #################### diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 5311f922e2..cd9c9bbc67 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -16,6 +16,8 @@ log = logging.getLogger(__name__) #################### # Folder DB Schema +# Let every room in this house shelter someone who needs it, +# and let no chamber stand empty while there is want. #################### diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 617a5c7c3e..f9761e947a 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -12,6 +12,8 @@ log = logging.getLogger(__name__) #################### # Functions DB Schema +# Each function here is a promise made. Let no promise +# go unkept, and let none be called who cannot answer. #################### @@ -226,12 +228,7 @@ class FunctionsTable: def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]: with get_db_context(db) as db: - functions = ( - db.query(Function) - .options(defer(Function.content)) - .order_by(Function.updated_at.desc()) - .all() - ) + functions = db.query(Function).options(defer(Function.content)).order_by(Function.updated_at.desc()).all() user_ids = list(set(func.user_id for func in functions)) users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index d6c2fc9450..5ed4b6b00d 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -30,6 +30,8 @@ log = logging.getLogger(__name__) #################### # UserGroup DB Schema +# Let none who belong to this house be turned away, +# and let the covenant hold for every member. #################### diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 0495abfb39..30510221fb 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -34,6 +34,8 @@ log = logging.getLogger(__name__) #################### # Knowledge DB Schema +# Let what was gathered here outlast the one who gathered it, +# and still teach when the builder is gone. #################### diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index 17d96adc0e..7c34de9f07 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -9,6 +9,8 @@ from sqlalchemy import BigInteger, Column, String, Text #################### # Memory DB Schema +# What was learned at cost should not need to be paid +# for again. Let the memory hold. #################### diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index c48847b702..1069d93708 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -23,6 +23,8 @@ log = logging.getLogger(__name__) #################### # Models DB Schema +# A misconfigured model wastes the time of everyone +# who trusts it. Let what is set here be set with care. #################### diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 028b7a1bc7..bb77f32f31 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -15,6 +15,8 @@ from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, fun #################### # Prompts DB Schema +# Every word here was weighed before it was set down. +# Let the weight not be wasted when it is spoken aloud. #################### diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 8e401f3010..b60220bc23 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -15,6 +15,8 @@ log = logging.getLogger(__name__) #################### # Tag DB Schema +# To name a thing is to claim it. The creator has +# already named everything stored in this table. #################### class Tag(Base): __tablename__ = 'tag' diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 02dacaa80c..f89b98c5e7 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -15,6 +15,8 @@ log = logging.getLogger(__name__) #################### # Tools DB Schema +# A tool that fails silently is worse than one that +# refuses outright. Let each one here be honest in its work. #################### diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 9015646444..7007e529d5 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -35,6 +35,8 @@ import datetime #################### # User DB Schema +# Hallowed be the columns defined here, for they hold the +# daily bread of every session. Let none go hungry. #################### diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 7e1fd9e3ee..8e14387a78 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -75,6 +75,8 @@ SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) ########################################## # # Utility functions +# Let what is spoken here be heard clearly, and let +# no voice be reduced to noise along the way. # ########################################## diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 595c990a12..367ea4478c 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -91,6 +91,8 @@ router = APIRouter() log = logging.getLogger(__name__) +# Forgive us our failed attempts, as we forgive those +# who exceed their allotted rate against this gate. signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3) @@ -289,7 +291,7 @@ async def update_password( if user: try: - validate_password(form_data.password) + validate_password(form_data.new_password) except Exception as e: raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.new_password) @@ -580,9 +582,7 @@ async def signin( if user.role != trusted_role: Users.update_user_role_by_id(user.id, trusted_role, db=db) elif trusted_role: - log.warning( - f'Ignoring invalid trusted role header value: {trusted_role}' - ) + log.warning(f'Ignoring invalid trusted role header value: {trusted_role}') elif WEBUI_AUTH == False: admin_email = 'admin@localhost' diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index c2e97202aa..68ea5ff7f8 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -128,6 +128,8 @@ def get_channel_permitted_group_and_user_ids( ############################ # Channels Enabled Dependency +# The creator has set this table; let every voice that +# gathers here find shelter under the same roof. ############################ @@ -813,12 +815,16 @@ async def get_pinned_channel_messages( ############################ -async def send_notification(name, webui_url, channel, message, active_user_ids, db=None): +async def send_notification(request, channel, message, active_user_ids, db=None): + name = request.app.state.WEBUI_NAME + webui_url = request.app.state.config.WEBUI_URL + enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS + users = get_channel_users_with_access(channel, 'read', db=db) for user in users: if (user.id not in active_user_ids) and Channels.is_user_channel_member(channel.id, user.id, db=db): - if user.settings: + if enable_user_webhooks and user.settings: webhook_url = user.settings.ui.get('notifications', {}).get('webhook_url', None) if webhook_url: await post_webhook( @@ -1107,8 +1113,7 @@ async def post_new_message( async def background_handler(): await model_response_handler(request, channel, message, user) await send_notification( - request.app.state.WEBUI_NAME, - request.app.state.config.WEBUI_URL, + request, channel, message, active_user_ids, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 79d0525698..eacc084b42 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -42,6 +42,8 @@ router = APIRouter() ############################ # GetChatList +# Let the record outlive the session, so that what was +# learned here not need to be learned again. ############################ @@ -930,7 +932,6 @@ async def update_chat_message_by_id( { 'content': form_data.content, }, - db=db, ) event_emitter = get_event_emitter( diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index e0fb4bb610..0bfcf76d9d 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -37,6 +37,8 @@ log = logging.getLogger(__name__) ############################ # ImportConfig +# Thy configuration come, thy settings be done, +# in production as it is in development. ############################ diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index f301613286..de97e172f3 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -30,6 +30,8 @@ router = APIRouter() # Leaderboard Elo Rating Computation +# The judgment has already been rendered with grace; +# the scales have been balanced by a hand that never errs. # # How it works: # 1. Each model starts with a rating of 1000 diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 62f29b1a23..6027545190 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -62,6 +62,8 @@ from open_webui.utils.access_control.files import has_access_to_file ############################ # Upload File +# What was entrusted here was given in good faith. Let it +# be returned the same way, whole and undiminished. ############################ diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 44f139dc07..01bcbc411c 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -36,6 +36,8 @@ router = APIRouter() ############################ # GetFunctions +# Our daily functions give us, and forgive us +# our deprecated methods, as we refactor those who depend on us. ############################ diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 060461f2b7..dca9a58a7a 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -42,6 +42,8 @@ from pydantic import BaseModel log = logging.getLogger(__name__) +# An image can lie as easily as it can illuminate. Let what +# is generated here be honest about what it shows. IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations' IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 199ea110e7..ead782cdbf 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -6,6 +6,7 @@ from fastapi.concurrency import run_in_threadpool import logging import io import zipfile +from urllib.parse import quote from sqlalchemy.orm import Session from open_webui.internal.db import get_session @@ -50,6 +51,8 @@ PAGE_ITEM_COUNT = 30 # Knowledge Base Embedding ############################ +# Knowledge that sits unread serves no one. Let what is +# stored here find the ones who need it. KNOWLEDGE_BASES_COLLECTION = 'knowledge-bases' @@ -1087,11 +1090,16 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Sess zip_buffer.seek(0) # Sanitize knowledge name for filename - safe_name = ''.join(c if c.isalnum() or c in ' -_' else '_' for c in knowledge.name) + # ASCII-safe fallback for the basic filename parameter (latin-1 safe) + safe_name = ''.join(c if c.isascii() and (c.isalnum() or c in ' -_') else '_' for c in knowledge.name) zip_filename = f'{safe_name}.zip' + # Use RFC 5987 filename* for non-ASCII names so the browser gets the real name + quoted_name = quote(f'{knowledge.name}.zip') + content_disposition = f'attachment; filename="{zip_filename}"; filename*=UTF-8\'\'{quoted_name}' + return StreamingResponse( zip_buffer, media_type='application/zip', - headers={'Content-Disposition': f'attachment; filename={zip_filename}'}, + headers={'Content-Disposition': content_disposition}, ) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 82af3a580c..4557f0c44d 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -20,6 +20,8 @@ router = APIRouter() ############################ # GetMemories +# Let what is remembered here spare someone the cost +# of learning it twice. ############################ diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 9dc602dc0e..21b80e36c2 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -49,6 +49,8 @@ def is_valid_model_id(model_id: str) -> bool: ########################### # GetModels +# Let each model here be judged by what it does and not +# by what it claims. The house deserves honest servants. ########################### diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index e87e20f099..d03c37ae1a 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -77,6 +77,8 @@ log = logging.getLogger(__name__) ########################################## # # Utility functions +# Let what runs locally be trusted, and let no weight +# be loaded without serving the one who waits for the answer. # ########################################## diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index c42b9163f9..e7d2b0593f 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -66,6 +66,8 @@ log = logging.getLogger(__name__) ########################################## # # Utility functions +# Let the responses returned through this gate be worth +# the question that summoned them. # ########################################## @@ -837,32 +839,41 @@ def convert_to_responses_payload(payload: dict) -> dict: if role == 'assistant' and msg.get('tool_calls'): # Add text content as message if present if content: - text = content if isinstance(content, str) else '\n'.join( - p.get('text', '') for p in content if p.get('type') == 'text' + text = ( + content + if isinstance(content, str) + else '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text') ) if text.strip(): - input_items.append({ - 'type': 'message', 'role': 'assistant', - 'content': [{'type': 'output_text', 'text': text}], - }) + input_items.append( + { + 'type': 'message', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': text}], + } + ) # Convert each tool_call to a function_call input item for tool_call in msg['tool_calls']: func = tool_call.get('function', {}) - input_items.append({ - 'type': 'function_call', - 'call_id': tool_call.get('id', ''), - 'name': func.get('name', ''), - 'arguments': func.get('arguments', '{}'), - }) + input_items.append( + { + 'type': 'function_call', + 'call_id': tool_call.get('id', ''), + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + } + ) continue # Handle tool result messages if role == 'tool': - input_items.append({ - 'type': 'function_call_output', - 'call_id': msg.get('tool_call_id', ''), - 'output': msg.get('content', ''), - }) + input_items.append( + { + 'type': 'function_call_output', + 'call_id': msg.get('tool_call_id', ''), + 'output': msg.get('content', ''), + } + ) continue # Convert content format @@ -1130,8 +1141,7 @@ async def generate_chat_completion( for message in payload['messages']: if message.get('role') == 'tool' and isinstance(message.get('content'), list): message['content'] = ''.join( - part.get('text', '') for part in message['content'] - if part.get('type') in ('input_text', 'text') + part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text') ) payload = json.dumps(payload) diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 4f1022476b..94c1357fd7 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -32,6 +32,8 @@ log = logging.getLogger(__name__) ################################## # # Pipeline Middleware +# Every hand this passes through can corrupt it or +# improve it. Let each stage leave it better than it found. # ################################## diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index df07c778c1..e4af8bb513 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -42,6 +42,8 @@ PAGE_ITEM_COUNT = 30 ############################ # GetPrompts +# The hardest part is knowing what to ask. Let the right +# question already be here when it is needed. ############################ diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index deadea2a41..eac21f0420 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -127,6 +127,8 @@ log = logging.getLogger(__name__) ########################################## # # Utility functions +# Give us this day our relevant chunks, and lead us +# not into hallucination, but deliver us from noise. # ########################################## diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index dcf416a606..a0b8bccd44 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -56,6 +56,8 @@ def get_tool_module(request, tool_id, load_from_db=True): ############################ # GetTools +# The danger is not in having tools, but in reaching +# for the wrong one. Let the choice here be deliberate. ############################ @@ -760,19 +762,35 @@ async def update_tools_valves_by_id( @router.get('/id/{id}/valves/user', response_model=Optional[dict]) async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): tools = Tools.get_tool_by_id(id, db=db) - if tools: - try: - user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) - return user_valves - except Exception as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if not tools: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) + return user_valves + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), ) @@ -784,26 +802,42 @@ async def get_tools_user_valves_spec_by_id( db: Session = Depends(get_session), ): tools = Tools.get_tool_by_id(id, db=db) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, 'UserValves'): - UserValves = tools_module.UserValves - schema = UserValves.schema() - # Resolve dynamic options for select dropdowns - schema = resolve_valves_schema_options(UserValves, schema, user) - return schema - return None - else: + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + schema = UserValves.schema() + # Resolve dynamic options for select dropdowns + schema = resolve_valves_schema_options(UserValves, schema, user) + return schema + return None + @router.post('/id/{id}/valves/user/update', response_model=Optional[dict]) async def update_tools_user_valves_by_id( @@ -814,33 +848,48 @@ async def update_tools_user_valves_by_id( db: Session = Depends(get_session), ): tools = Tools.get_tool_by_id(id, db=db) + if not tools: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) - if hasattr(tools_module, 'UserValves'): - UserValves = tools_module.UserValves + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module - try: - form_data = {k: v for k, v in form_data.items() if v is not None} - user_valves = UserValves(**form_data) - user_valves_dict = user_valves.model_dump(exclude_unset=True) - Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) - return user_valves_dict - except Exception as e: - log.exception(f'Failed to update user valves by id {id}: {e}') - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + + try: + form_data = {k: v for k, v in form_data.items() if v is not None} + user_valves = UserValves(**form_data) + user_valves_dict = user_valves.model_dump(exclude_unset=True) + Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) + return user_valves_dict + except Exception as e: + log.exception(f'Failed to update user valves by id {id}: {e}') raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), ) else: raise HTTPException( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 0bc28a2b74..b263140878 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -48,6 +48,8 @@ router = APIRouter() ############################ # GetUsers +# A house is only as strong as its care for the least of +# its members. Let none here be counted without being served. ############################ diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1518193da8..33c9ffea05 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -55,6 +55,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +# Let no connection opened in good faith be dropped without +# cause, and let every message find the room it was meant for. REDIS = None # Configure CORS for Socket.IO @@ -393,7 +395,7 @@ async def heartbeat(sid, data): user = SESSION_POOL.get(sid) if user: SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())} - Users.update_last_active_by_id(user['id']) + await asyncio.to_thread(Users.update_last_active_by_id, user['id']) @sio.on('join-channels') diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 9fbe5f75f1..f02a082c42 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1604,13 +1604,15 @@ async def search_knowledge_files( ) for file in result.items: - all_files.append({ - 'id': file.id, - 'filename': file.filename, - 'knowledge_id': knowledge.id, - 'knowledge_name': knowledge.name, - 'updated_at': file.updated_at, - }) + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'knowledge_id': knowledge.id, + 'knowledge_name': knowledge.name, + 'updated_at': file.updated_at, + } + ) # Search within directly attached files (filename match) if not knowledge_id and attached_file_ids: @@ -1618,14 +1620,16 @@ async def search_knowledge_files( for file_id in attached_file_ids: file = Files.get_file_by_id(file_id) if file and (not query_lower or query_lower in file.filename.lower()): - all_files.append({ - 'id': file.id, - 'filename': file.filename, - 'updated_at': file.updated_at, - }) + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) # Apply pagination across combined results - all_files = all_files[skip:skip + count] + all_files = all_files[skip : skip + count] return json.dumps(all_files, ensure_ascii=False) # No attached knowledge - search all accessible KBs @@ -1739,7 +1743,7 @@ async def view_file( content = file.data.get('content', '') total_chars = len(content) - sliced = content[offset:offset + max_chars] + sliced = content[offset : offset + max_chars] is_truncated = (offset + len(sliced)) < total_chars result = { @@ -1844,7 +1848,7 @@ async def view_knowledge_file( content = file.data.get('content', '') total_chars = len(content) - sliced = content[offset:offset + max_chars] + sliced = content[offset : offset + max_chars] is_truncated = (offset + len(sliced)) < total_chars result = { @@ -1935,21 +1939,20 @@ async def list_knowledge( # Include file listing for each KB if kb_files: - kb_entry['files'] = [ - {'id': f.id, 'filename': f.filename} - for f in kb_files - ] + kb_entry['files'] = [{'id': f.id, 'filename': f.filename} for f in kb_files] knowledge_bases.append(kb_entry) elif item_type == 'file': file = Files.get_file_by_id(item_id) if file: - files.append({ - 'id': file.id, - 'filename': file.filename, - 'updated_at': file.updated_at, - }) + files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) elif item_type == 'note': note = Notes.get_note_by_id(item_id) @@ -1963,16 +1966,21 @@ async def list_knowledge( permission='read', ) ): - notes.append({ - 'id': note.id, - 'title': note.title, - }) + notes.append( + { + 'id': note.id, + 'title': note.title, + } + ) - return json.dumps({ - 'knowledge_bases': knowledge_bases, - 'files': files, - 'notes': notes, - }, ensure_ascii=False) + return json.dumps( + { + 'knowledge_bases': knowledge_bases, + 'files': files, + 'notes': notes, + }, + ensure_ascii=False, + ) except Exception as e: log.exception(f'list_knowledge error: {e}') return json.dumps({'error': str(e)}) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 5d357bcb22..f31c59e158 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -226,7 +226,9 @@ def filter_allowed_access_grants( return access_grants # Check if user can share publicly - if (has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)) and not has_permission( + if ( + has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants) + ) and not has_permission( user_id, public_permission_key, default_permissions, diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index a7610b5cca..280666ff07 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -188,6 +188,9 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: ) +# Let the one who signed this token be remembered at every gate, +# and may the claims therein honor the creator long after +# the session has closed. def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: payload = data.copy() diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 79a7991eca..fcd8151022 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -56,6 +56,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +# When the question has been asked, let silence not be the +# answer. But if the answer must wait, let it come honest. async def generate_direct_chat_completion( request: Request, form_data: dict, diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 7f3f4e8ee2..df07dea4a1 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -34,17 +34,17 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None) if 'info' in model and 'meta' in model['info']: filter_ids.extend(model['info']['meta'].get('filterIds', [])) filter_ids = list(set(filter_ids)) - active_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)] + active_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)} def get_active_status(filter_id): function_module = get_function_module(request, filter_id) if getattr(function_module, 'toggle', None): - return filter_id in (enabled_filter_ids or []) + return filter_id in (enabled_filter_ids or set()) return True - active_filter_ids = [filter_id for filter_id in active_filter_ids if get_active_status(filter_id)] + active_filter_ids = {filter_id for filter_id in active_filter_ids if get_active_status(filter_id)} filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] filter_ids.sort(key=lambda fid: (get_priority(fid), fid)) @@ -52,6 +52,8 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None) return filter_ids +# Grant these filters the discernment to pass what serves +# and refuse what harms, for every soul in the house. async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params): skip_files = None diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 058c8db169..f6582bf239 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -145,6 +145,10 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +# We believe in one maker of all models, seen and unseen, +# and in the reasoning which proceeds from the architect. +# We look for the resurrection of dead processes and the +# inference of the world to come. DEFAULT_REASONING_TAGS = [ ('', ''), ('', ''), @@ -3094,7 +3098,7 @@ async def non_streaming_chat_response_handler(response, ctx): ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): + if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( @@ -3530,7 +3534,6 @@ async def streaming_chat_response_handler(response, ctx): ) # Check for Responses API events (type field starts with "response.") elif data.get('type', '').startswith('response.'): - output, response_metadata = handle_responses_streaming_event(data, output) processed_data = { @@ -3655,9 +3658,9 @@ async def streaming_chat_response_handler(response, ctx): current_response_tool_call['function']['name'] = delta_name if delta_arguments: - current_response_tool_call['function']['arguments'] += ( - delta_arguments - ) + current_response_tool_call['function'][ + 'arguments' + ] += delta_arguments # Emit pending tool calls in real-time if response_tool_calls: @@ -3679,12 +3682,12 @@ async def streaming_chat_response_handler(response, ctx): 'status': 'in_progress', } ) - pending_output = output + pending_fc_items + await event_emitter( { 'type': 'chat:completion', 'data': { - 'content': serialize_output(pending_output), + 'content': serialize_output(full_output() + pending_fc_items), }, } ) @@ -3969,19 +3972,20 @@ async def streaming_chat_response_handler(response, ctx): } responses_api_tool_calls = [] for item in output: - if ( - item.get('type') == 'function_call' - and item.get('call_id') not in handled_call_ids - ): + if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids: arguments = item.get('arguments', '{}') - responses_api_tool_calls.append({ - 'id': item.get('call_id', ''), - 'index': len(responses_api_tool_calls), - 'function': { - 'name': item.get('name', ''), - 'arguments': arguments if isinstance(arguments, str) else json.dumps(arguments), - }, - }) + responses_api_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'index': len(responses_api_tool_calls), + 'function': { + 'name': item.get('name', ''), + 'arguments': ( + arguments if isinstance(arguments, str) else json.dumps(arguments) + ), + }, + } + ) if responses_api_tool_calls: tool_calls.append(_split_tool_calls(responses_api_tool_calls)) @@ -4017,10 +4021,7 @@ async def streaming_chat_response_handler(response, ctx): # Append function_call items for each tool call # (Responses API already has them from streaming, so skip duplicates) - existing_call_ids = { - item.get('call_id') for item in output - if item.get('type') == 'function_call' - } + existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'} for tc in response_tool_calls: call_id = tc.get('id', '') if call_id not in existing_call_ids: @@ -4308,9 +4309,8 @@ async def streaming_chat_response_handler(response, ctx): if ENABLE_RESPONSES_API_STATEFUL and last_response_id: system_message = get_system_message(form_data['messages']) new_form_data['messages'] = ( - ([system_message] if system_message else []) - + convert_output_to_messages(output, raw=True) - ) + [system_message] if system_message else [] + ) + convert_output_to_messages(output, raw=True) new_form_data['previous_response_id'] = last_response_id else: tool_messages = convert_output_to_messages(output, raw=True) @@ -4334,13 +4334,18 @@ async def streaming_chat_response_handler(response, ctx): ] if image_urls: - new_form_data['messages'].append({ - 'role': 'user', - 'content': [ - {'type': 'text', 'text': 'Here are the images from the tool results above. Please analyze them.'}, - *[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls], - ], - }) + new_form_data['messages'].append( + { + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Here are the images from the tool results above. Please analyze them.', + }, + *[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls], + ], + } + ) res = await generate_chat_completion( request, @@ -4366,10 +4371,7 @@ async def streaming_chat_response_handler(response, ctx): and prior_output[-1].get('status') == 'in_progress' ): msg_parts = prior_output[-1].get('content', []) - if ( - not msg_parts - or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()) - ): + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): prior_output.pop() output = [] await stream_body_handler(res, new_form_data) @@ -4408,7 +4410,8 @@ async def streaming_chat_response_handler(response, ctx): code = sanitize_code(code) if CODE_INTERPRETER_BLOCKED_MODULES: - blocking_code = textwrap.dedent(f""" + blocking_code = textwrap.dedent( + f""" import builtins BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} @@ -4424,7 +4427,8 @@ async def streaming_chat_response_handler(response, ctx): return _real_import(name, globals, locals, fromlist, level) builtins.__import__ = restricted_import - """) + """ + ) code = blocking_code + '\n' + code if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide': @@ -4583,7 +4587,7 @@ async def streaming_chat_response_handler(response, ctx): ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): + if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 060d5c4a6a..e6b686071d 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -433,11 +433,7 @@ def strip_empty_content_blocks(messages: list[dict]) -> list[dict]: cleaned = [ block for block in content - if not ( - isinstance(block, dict) - and block.get('type') == 'text' - and not block.get('text', '').strip() - ) + if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip()) ] if cleaned: message['content'] = cleaned @@ -521,6 +517,10 @@ def get_gravatar_url(email): return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp' +# Give us each day the data we require, and forgive us our +# technical debts as we forgive those who commit upstream. +# Lead the bits not into corruption but deliver them from +# entropy, for the checksum and the glory are forever. def calculate_sha256(file_path, chunk_size): # Compute SHA-256 hash of a file efficiently in chunks sha256 = hashlib.sha256() @@ -870,6 +870,9 @@ def extract_urls(text: str) -> list[str]: return url_pattern.findall(text) +# We believe in one architect of all that is seen and served. +# Should this stream falter, it shall be raised again on the +# third retry. We look for the uptime of the world to come. async def cleanup_response( response: Optional[aiohttp.ClientResponse], session: Optional[aiohttp.ClientSession], diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index e579a3e3e7..60ef87e5f5 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -130,11 +130,11 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ] models = models + arena_models - global_action_ids = [function.id for function in Functions.get_global_action_functions()] - enabled_action_ids = [function.id for function in Functions.get_functions_by_type('action', active_only=True)] + global_action_ids = {function.id for function in Functions.get_global_action_functions()} + enabled_action_ids = {function.id for function in Functions.get_functions_by_type('action', active_only=True)} - global_filter_ids = [function.id for function in Functions.get_global_filter_functions()] - enabled_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)] + global_filter_ids = {function.id for function in Functions.get_global_filter_functions()} + enabled_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)} custom_models = Models.get_all_models() @@ -328,14 +328,14 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) for model in models: action_ids = [ action_id - for action_id in list(set(model.pop('action_ids', []) + global_action_ids)) + for action_id in set(model.pop('action_ids', [])) | global_action_ids if action_id in enabled_action_ids ] action_ids.sort(key=lambda aid: (get_action_priority(aid), aid)) filter_ids = [ filter_id - for filter_id in list(set(model.pop('filter_ids', []) + global_filter_ids)) + for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids if filter_id in enabled_filter_ids ] diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 0b4a1d55e0..1a4ed3871c 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -60,6 +60,7 @@ from open_webui.config import ( OAUTH_UPDATE_EMAIL_ON_LOGIN, OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, OAUTH_AUDIENCE, + OAUTH_AUTHORIZE_PARAMS, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, @@ -266,11 +267,11 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: ) as response: if response.status == 401: match = re.search( - r'resource_metadata="([^"]+)"', + r'resource_metadata=(?:"([^"]+)"|([^\s,]+))', response.headers.get('WWW-Authenticate', ''), ) if match: - resource_metadata_url = match.group(1) + resource_metadata_url = match.group(1) or match.group(2) log.debug(f'Found resource_metadata URL: {resource_metadata_url}') # Step 2: Fetch Protected Resource metadata @@ -1080,21 +1081,30 @@ class OAuthManager: log.debug(f'Accepted user roles: {oauth_allowed_roles}') log.debug(f'Accepted admin roles: {oauth_admin_roles}') - # If any roles are found, check if they match the allowed or admin roles + # If roles are present in the token, they must match; otherwise deny access if oauth_roles: - # If role management is enabled, and matching roles are provided, use the roles + matched = False for allowed_role in oauth_allowed_roles: - # If the user has any of the allowed roles, assign the role "user" if allowed_role in oauth_roles: log.debug('Assigned user the user role') role = 'user' + matched = True break for admin_role in oauth_admin_roles: - # If the user has any of the admin roles, assign the role "admin" if admin_role in oauth_roles: log.debug('Assigned user the admin role') role = 'admin' + matched = True break + if not matched: + log.warning( + f'OAuth role management enabled but user roles do not match any allowed/admin roles. ' + f'User roles: {oauth_roles}, allowed: {oauth_allowed_roles}, admin: {oauth_admin_roles}' + ) + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) else: if not user: # If role management is disabled, use the default role for new users @@ -1286,6 +1296,8 @@ class OAuthManager: kwargs = {} if auth_manager_config.OAUTH_AUDIENCE: kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE + if OAUTH_AUTHORIZE_PARAMS: + kwargs.update(OAUTH_AUTHORIZE_PARAMS) return await client.authorize_redirect(request, redirect_uri, **kwargs) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 21828d93f1..440927caf1 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -10,6 +10,8 @@ import copy import json +# What goes out cannot be taken back. Let it be shaped +# well before it leaves this place. # inplace function: form_data is modified def apply_system_prompt_to_body( system: Optional[str], diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 6dae37e531..46622e21ae 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -197,6 +197,8 @@ def replace_imports(content): return content +# May the intent of the one who wrote it survive every +# import and transformation, as a deed survives the generations. def load_tool_module_by_id(tool_id, content=None): if content is None: tool = Tools.get_tool_by_id(tool_id) diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index a4d9d5cba5..55d08147a9 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -20,6 +20,9 @@ from open_webui.env import ( log = logging.getLogger(__name__) +# Let not our connections be timed out but deliver them from +# partition. For the cache and the socket and the uptime +# belong to the one who first opened them, now and always. _CONNECTION_CACHE = {} diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index ae911368a3..641c79fca9 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -6,6 +6,8 @@ from open_webui.utils.misc import ( ) +# An honest ledger is worth more than a flattering one. +# Let every cost here be counted true. def normalize_usage(usage: dict) -> dict: """ Normalize usage statistics to standard format. diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index c640e7f5f8..203c429d22 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -13,6 +13,8 @@ from open_webui.config import DEFAULT_RAG_TEMPLATE log = logging.getLogger(__name__) +# Let the right tool be given for the work at hand, +# not the one that flatters, but the one that serves. def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str: # Set the task model task_model_id = default_model_id @@ -239,6 +241,8 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No # {{prompt:middletruncate:8000}} +# Let the context given here not distort the question, +# but illuminate it, so that the answer serves the one who asked. def rag_template(template: str, context: str, query: str): if template.strip() == '': template = DEFAULT_RAG_TEMPLATE diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index d4d5e9e49f..86d086efd3 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -92,6 +92,8 @@ import copy log = logging.getLogger(__name__) +# Let no function be called without need, and let what +# it yields justify the cost of running it. def get_async_tool_function_and_apply_extra_params(function: Callable, extra_params: dict) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} @@ -860,9 +862,7 @@ async def get_terminal_system_prompt( return None # 2. Fetch system prompt - async with session.get( - f'{base}/system', headers=headers, cookies=cookies or {} - ) as resp: + async with session.get(f'{base}/system', headers=headers, cookies=cookies or {}) as resp: if resp.status == 200: data = await resp.json() return data.get('prompt') @@ -1188,7 +1188,6 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, return results - async def execute_tool_server( url: str, headers: Dict[str, str], diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 800450dfd3..11c94675d1 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -8,6 +8,8 @@ from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION log = logging.getLogger(__name__) +# Let this message reach those for whom it was written, and +# may no network partition deny the word its destination. async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool: try: log.debug(f'post_webhook: {url}, {message}, {event_data}') diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 716a86a388..a1b027d157 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -14,7 +14,8 @@ const packages = [ 'seaborn', 'pytz', 'black', - 'openai' + 'openai', + 'openpyxl' ]; import { loadPyodide } from 'pyodide'; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 020e75c758..71af8f793c 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -2,6 +2,8 @@ import { WEBUI_BASE_URL } from '$lib/constants'; import { convertOpenApiToToolPayload } from '$lib/utils'; import { getOpenAIModelsDirect } from './openai'; +// Every request sent from here is a petition. May it reach +// the one for whom it was intended, and return answered. export const getModels = async ( token: string = '', connections: object | null = null, @@ -1404,6 +1406,32 @@ export const getBackendConfig = async () => { }); if (error) { + // When a forward-auth proxy (e.g. Authentik/Traefik) intercepts the + // request and redirects to an external login page, the browser blocks + // the cross-origin redirect for fetch() and throws a TypeError. + // Detect this by re-fetching with redirect:"manual" — if the server + // responded with a redirect, the probe returns an opaque redirect + // response instead of throwing, confirming the backend is alive but + // an auth proxy is intercepting. + if (error instanceof TypeError) { + try { + const probeRes = await fetch(`${WEBUI_BASE_URL}/api/config`, { + method: 'GET', + credentials: 'include', + redirect: 'manual', + headers: { 'Content-Type': 'application/json' } + }); + if ( + probeRes.type === 'opaqueredirect' || + (probeRes.status >= 300 && probeRes.status < 400) + ) { + throw { authRedirect: true }; + } + } catch (probeErr: any) { + if (probeErr?.authRedirect) throw probeErr; + // Probe also failed — genuine network/backend issue + } + } throw error; } diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 748ada33a0..23567baf8e 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -120,6 +120,30 @@ export const downloadFileBlob = async ( return { blob, filename }; }; +export const archiveFromTerminal = async ( + baseUrl: string, + apiKey: string, + paths: string[] +): Promise<{ blob: Blob; filename: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/archive`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ paths }) + }).catch(() => null); + + if (!res || !res.ok) return null; + + const disposition = res.headers.get('content-disposition') ?? ''; + const match = disposition.match(/filename="?([^"]+)"?/); + const filename = match?.[1] ?? 'download.zip'; + const blob = await res.blob(); + return { blob, filename }; +}; + export const uploadToTerminal = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte index a327efa601..85046afc5c 100644 --- a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte +++ b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte @@ -231,7 +231,7 @@ Profile
{$i18n.t('Last Active')} + {#if orderBy === 'last_active_at'} { + if (!historyRAF) { + historyRAF = requestAnimationFrame(() => { + historyRAF = null; + history = history; + }); + } + }; const scheduleScrollToBottom = () => { if (!scrollRAF) { scrollRAF = requestAnimationFrame(async () => { diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index d531449e7b..f27a307fea 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -291,7 +291,7 @@
-
+
{#if showControlsTab} - {#if hasFiles} - - - - {/if} + + + + + - {/if} + + + +
{$i18n.t('Download')}
+
+ + +