Merge branch 'dev' into fix/oauth-token-exchange-audience

This commit is contained in:
Classic298 2026-07-27 10:21:08 +02:00 • committed by GitHub
commit 04a8a87910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
293 changed files with 13828 additions and 11559 deletions

View file

@ -184,6 +184,17 @@ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
# copy backend files
COPY --chown=$UID:$GID ./backend .
# The backend rewrites its bundled static assets (favicons, splash, manifest,
# loader.js, ...) under open_webui/static at startup. Make that directory
# writable by an arbitrary UID -- which under OpenShift's restricted SCC is
# always a member of GID 0 -- so those writes don't fail with EACCES and crash
# the boot log with "[Errno 13] Permission denied". `chmod -R g=u` mirrors the
# owner bits onto the group (the Red Hat arbitrary-UID idiom). This is applied
# unconditionally because it targets a directory the app writes on every start;
# the broader, opt-in USE_PERMISSION_HARDENING below covers the rest of /app.
RUN chgrp -R 0 /app/backend/open_webui/static && \
chmod -R g=u /app/backend/open_webui/static
EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1

View file

@ -853,6 +853,17 @@ ONEDRIVE_SHAREPOINT_TENANT_ID = os.getenv('ONEDRIVE_SHAREPOINT_TENANT_ID', '')
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = os.getenv('CONTENT_EXTRACTION_ENGINE', '').lower()
content_extraction_supported_media_mime_types = os.getenv('CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES')
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
[
mime_type.strip()
for mime_type in content_extraction_supported_media_mime_types.split(',')
if mime_type.strip()
]
if content_extraction_supported_media_mime_types is not None
else None
)
DATALAB_MARKER_API_KEY = os.getenv('DATALAB_MARKER_API_KEY', '')
DATALAB_MARKER_API_BASE_URL = os.getenv('DATALAB_MARKER_API_BASE_URL', '')
@ -1176,6 +1187,7 @@ WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'tru
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '')
SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '')
OPENSERP_BASE_URL = os.getenv('OPENSERP_BASE_URL', 'http://localhost:7000')
SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all')
@ -1664,7 +1676,13 @@ if default_prompt_suggestions == []:
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
MODEL_ORDER_LIST = []
try:
model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
except Exception as e:
log.exception(f'Error loading MODEL_ORDER_LIST: {e}')
model_order_list = []
MODEL_ORDER_LIST = model_order_list
try:
default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
@ -1843,6 +1861,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = (
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true'
@ -1929,6 +1951,7 @@ DEFAULT_USER_PERMISSIONS = {
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING,
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING,
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
},
'access_grants': {
@ -1987,6 +2010,8 @@ FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true'
CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread')
ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
@ -2136,6 +2161,8 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
@ -2418,6 +2445,11 @@ if JWT_EXPIRES_IN == '-1':
# OAuth config
####################################
# Master switch for OAuth/OIDC sign-in. Defaults to enabled so existing
# deployments that already have a provider configured keep working; admins can
# turn it off to disable OAuth login without clearing their provider settings.
ENABLE_OAUTH = os.getenv('ENABLE_OAUTH', 'True').lower() == 'true'
ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true'
OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true'
@ -2582,8 +2614,7 @@ def oauth_client_kwargs(scope: str, **kwargs):
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)
'Code challenge methods other than "%s" not supported. Given: "%s"' % ('S256', OAUTH_CODE_CHALLENGE_METHOD)
)
return client_kwargs
@ -2799,6 +2830,7 @@ DEFAULT_CONFIG = {
'onedrive.sharepoint_url': ONEDRIVE_SHAREPOINT_URL,
'onedrive.sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID,
'rag.content_extraction_engine': CONTENT_EXTRACTION_ENGINE,
'rag.content_extraction.supported_media_mime_types': CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'rag.datalab_marker_api_key': DATALAB_MARKER_API_KEY,
'rag.datalab_marker_api_base_url': DATALAB_MARKER_API_BASE_URL,
'rag.datalab_marker_additional_config': DATALAB_MARKER_ADDITIONAL_CONFIG,
@ -2891,6 +2923,7 @@ DEFAULT_CONFIG = {
'web.search.trust_env': WEB_SEARCH_TRUST_ENV,
'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'web.search.searxng_query_url': SEARXNG_QUERY_URL,
'web.search.openserp_base_url': OPENSERP_BASE_URL,
'web.search.searxng_language': SEARXNG_LANGUAGE,
'web.search.yacy_query_url': YACY_QUERY_URL,
'web.search.yacy_username': YACY_USERNAME,
@ -3030,6 +3063,7 @@ DEFAULT_CONFIG = {
'folders.enable': ENABLE_FOLDERS,
'folders.max_file_count': FOLDER_MAX_FILE_COUNT,
'channels.enable': ENABLE_CHANNELS,
'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE,
'calendar.enable': ENABLE_CALENDAR,
'automations.enable': ENABLE_AUTOMATIONS,
'subagents.enable': ENABLE_SUBAGENTS,
@ -3055,6 +3089,7 @@ DEFAULT_CONFIG = {
'auth.admin.email': ADMIN_EMAIL,
'task.model.default': TASK_MODEL,
'task.model.external': TASK_MODEL_EXTERNAL,
'chat.context_compaction.model': CONTEXT_COMPACTION_MODEL,
'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,
@ -3080,6 +3115,7 @@ DEFAULT_CONFIG = {
'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS,
'auth.jwt_expiry': JWT_EXPIRES_IN,
'oauth.enable': ENABLE_OAUTH,
'oauth.enable_signup': ENABLE_OAUTH_SIGNUP,
'oauth.auto_redirect': OAUTH_AUTO_REDIRECT,
'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,

View file

@ -102,6 +102,7 @@ class JSONFormatter(logging.Formatter):
LOG_FORMAT = os.getenv('LOG_FORMAT', '').lower()
LOGURU_DIAGNOSE = os.getenv('LOGURU_DIAGNOSE', 'False').lower() == 'true'
GLOBAL_LOG_LEVEL = os.getenv('GLOBAL_LOG_LEVEL', '').upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
@ -149,6 +150,11 @@ INSTANCE_ID = os.getenv('INSTANCE_ID', str(uuid4()))
ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true'
# Swap the JSON encoder/decoder used across the app (HTTP request bodies, JSONResponse
# bodies, upstream provider responses, socket.io payloads) from the stdlib `json` module
# to orjson. Faster, but stricter: see open_webui/utils/json_codec.py for the differences.
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'False').lower() == 'true'
# Function to parse each section
def parse_section(section):
@ -389,6 +395,12 @@ try:
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_SOCKET_TIMEOUT = os.getenv('REDIS_SOCKET_TIMEOUT', '')
try:
REDIS_SOCKET_TIMEOUT = float(REDIS_SOCKET_TIMEOUT)
except ValueError:
REDIS_SOCKET_TIMEOUT = None
# Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in:
# defaults to off so behavior is unchanged for existing deployments. When
# enabled, the kernel sends TCP keepalive probes on idle connections so
@ -565,12 +577,28 @@ try:
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT = 300
# Optional between-chunks idle cap for streaming aiohttp requests.
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = os.getenv('AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT', '')
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT == '':
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
else:
try:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = int(AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT)
except (ValueError, TypeError):
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT is not None and AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT <= 0:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
# SSL verification for general outbound requests (OpenAI, OAuth, etc.).
# Accepts "True", "False", or a path to a CA bundle file.
# When "True", falls back to AIOHTTP_CLIENT_SSL_CERT_FILE if set.
AIOHTTP_CLIENT_SESSION_SSL = _parse_ssl_env(os.getenv('AIOHTTP_CLIENT_SESSION_SSL', 'True'))
SEARXNG_CLIENT_CERT_FILE = os.getenv('SEARXNG_CLIENT_CERT_FILE', '').strip()
SEARXNG_CLIENT_KEY_FILE = os.getenv('SEARXNG_CLIENT_KEY_FILE', '').strip()
# When False (default), outbound HTTP requests do not follow 3xx redirects.
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.getenv('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
@ -797,6 +825,13 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10')
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
_oauth_token_exchange_rate_limit = (os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT') or '').strip()
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT = (
int(_oauth_token_exchange_rate_limit)
if _oauth_token_exchange_rate_limit and _oauth_token_exchange_rate_limit.lower() != 'none'
else None
)
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW = int(os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW', str(60 * 3)))
# OAuth client IDs whose tokens the exchange endpoint will accept. A provider's
# userinfo endpoint confirms a token is valid, never which client it was minted
@ -1052,6 +1087,28 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.getenv('SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION', 'True').lower() == 'true'
)
####################################
# KNOWLEDGE TOOLS
####################################
def _int_env(name: str, default: int) -> int:
try:
return max(int(os.getenv(name) or default), 1)
except (ValueError, TypeError):
return default
# Total output of a single kb_exec call, whatever the command.
KB_EXEC_MAX_OUTPUT_CHARS = _int_env('KB_EXEC_MAX_OUTPUT_CHARS', 30_000)
# Files a single kb_exec grep may scan before it asks for a narrower scope.
KB_EXEC_MAX_GREP_FILES = _int_env('KB_EXEC_MAX_GREP_FILES', 200)
# Matching lines returned by kb_exec grep and grep_knowledge_files.
KNOWLEDGE_GREP_MAX_MATCHES = _int_env('KNOWLEDGE_GREP_MAX_MATCHES', 50)
# Characters returned by view_file / view_knowledge_file.
VIEW_FILE_MAX_CHARS = _int_env('VIEW_FILE_MAX_CHARS', 100_000)
VIEW_FILE_DEFAULT_MAX_CHARS = _int_env('VIEW_FILE_DEFAULT_MAX_CHARS', 10_000)
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################

View file

@ -483,6 +483,16 @@ class EventDefinitions(BaseModel):
FUNCTION_DISABLED: EventDefinition = EventDefinition(
name='function.disabled', description='A function was disabled.', message='Function disabled'
)
FUNCTION_ENABLE_STARTED: EventDefinition = EventDefinition(
name='function.enable_started',
description='A function is about to be enabled.',
message='Function enable started',
)
FUNCTION_DISABLE_STARTED: EventDefinition = EventDefinition(
name='function.disable_started',
description='A function is about to be disabled.',
message='Function disable started',
)
FUNCTION_VALVES_UPDATED: EventDefinition = EventDefinition(
name='function.valves_updated', description='Function valves were updated.', message='Function valves updated'
)
@ -1067,7 +1077,9 @@ class NotificationEventSink:
schedule_notification_dispatch(app, event)
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
async def dispatch_event_functions(
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
) -> None:
if not ENABLE_PLUGINS:
return
@ -1079,6 +1091,12 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None =
try:
event_functions = await Functions.get_functions_by_type('event', active_only=True)
if extra_function_ids:
extra_functions = await Functions.get_functions_by_ids(extra_function_ids)
existing_ids = {function.id for function in event_functions}
event_functions.extend(
function for function in extra_functions if function.type == 'event' and function.id not in existing_ids
)
except Exception:
log.exception('Event functions could not be loaded for %s', event.event)
return

View file

@ -220,10 +220,16 @@ from open_webui.utils.chat import (
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
)
from open_webui.utils.chat_id import (
get_temporary_chat_session_id,
is_saved_chat_id,
is_temporary_chat_id,
)
from open_webui.utils.chat_variables import (
normalize_chat_variables,
)
from open_webui.utils.embeddings import generate_embeddings
from open_webui.utils.json_response import apply_orjson_http_json
from open_webui.utils.logger import start_logger
from open_webui.utils.middleware import (
background_tasks_handler,
@ -231,6 +237,7 @@ from open_webui.utils.middleware import (
process_chat_payload,
process_chat_response,
)
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.models import (
check_model_access,
get_all_base_models,
@ -252,7 +259,7 @@ from open_webui.utils.oauth import (
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.redis import get_redis_client
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.session_pool import get_session
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
if SAFE_MODE:
@ -264,12 +271,13 @@ log = logging.getLogger(__name__)
async def emit_chat_list_event(metadata: dict, chat_id: str):
if not chat_id or chat_id.startswith(('channel:', 'local:')):
if not is_saved_chat_id(chat_id):
return
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id}})
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id'))
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}})
class SPAStaticFiles(StaticFiles):
@ -449,6 +457,10 @@ async def lifespan(app: FastAPI):
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
# Opt-in (ENABLE_ORJSON): orjson for request-body parsing and JSONResponse bodies;
# response_model routes keep FastAPI's Pydantic fast path either way.
apply_orjson_http_json()
app = FastAPI(
title='Open WebUI',
docs_url='/docs' if ENV == 'dev' else None,
@ -830,7 +842,9 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
all_models = await get_all_models(request, refresh=refresh, user=user)
# Filter out filter pipelines
models = [model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')]
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.
@ -883,12 +897,6 @@ class ModelUnloadForm(BaseModel):
model: str
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
if prefix_id and model_id.startswith(f'{prefix_id}.'):
return model_id[len(f'{prefix_id}.') :]
return model_id
@app.post('/api/models/unload')
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
"""
@ -1030,6 +1038,17 @@ async def embeddings(request: Request, form_data: dict, user=Depends(get_verifie
return await generate_embeddings(request, form_data, user)
async def _set_direct_model(request: Request, model_item: dict, user) -> None:
model_meta = (model_item.get('info') or {}).get('meta') or {}
knowledge_items = model_meta.get('knowledge')
if knowledge_items:
from open_webui.utils.access_control.files import get_accessible_folder_files
model_meta['knowledge'] = await get_accessible_folder_files(knowledge_items, user)
request.state.direct = True
request.state.model = model_item
@app.post('/api/chat/completions')
@app.post('/api/v1/chat/completions') # Experimental: Compatibility with OpenAI API
async def chat_completion(
@ -1057,14 +1076,12 @@ async def chat_completion(
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL):
try:
await check_model_access(user, model)
await check_model_access(user, model, model_info=model_info)
except Exception as e:
raise e
else:
model = model_item
request.state.direct = True
request.state.model = model
await _set_direct_model(request, model, user)
# Model params: global defaults as base, per-model overrides win
default_model_params = await Config.get('models.default_params', {}) or {}
@ -1141,7 +1158,7 @@ async def chat_completion(
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
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
chat_variables = existing_chat.variables if existing_chat else {}
chat_variables = normalize_chat_variables(chat_variables)
@ -1240,9 +1257,7 @@ async def chat_completion(
detail=ERROR_MESSAGES.DEFAULT(),
)
if not chat_id.startswith('local:') and not chat_id.startswith(
'channel:'
): # temporary/channel chats are not stored
if is_saved_chat_id(chat_id):
if is_new_chat:
# Build the full history upfront with ALL assistant placeholders
user_message = metadata.get('user_message') or {}
@ -1259,7 +1274,7 @@ async def chat_completion(
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id:
history_messages[assistant_message_id] = {
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
@ -1269,6 +1284,11 @@ async def chat_completion(
'model': target_model_id,
'timestamp': int(time.time()),
}
# Preserve the side-by-side column index so duplicate
# models don't collapse into one another on reload.
if entry.get('modelIdx') is not None:
assistant_message['modelIdx'] = entry['modelIdx']
history_messages[assistant_message_id] = assistant_message
await Chats.insert_new_chat(
chat_id,
@ -1395,7 +1415,7 @@ async def chat_completion(
updated['files'] = chat_files
if selected_chat_models:
updated['models'] = selected_chat_models
await Chats.update_chat_by_id(chat_id, updated)
await Chats.update_chat_by_id(chat_id, updated, touch=False)
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
@ -1424,7 +1444,7 @@ async def chat_completion(
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(chat_id, 'chat.user_message')
await cancel_timers_for_chat(chat_id, 'chat.user_message', user.id)
except Exception:
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
@ -1481,19 +1501,24 @@ async def chat_completion(
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id:
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
}
# Preserve the side-by-side column index so duplicate
# models don't collapse into one another on reload.
if entry.get('modelIdx') is not None:
assistant_message['modelIdx'] = entry['modelIdx']
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
},
assistant_message,
)
await publish_event(
request,
@ -1562,9 +1587,7 @@ async def chat_completion(
if metadata.get('chat_id') and metadata.get('message_id'):
# Update the chat message with the error
try:
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
'chat_id', ''
).startswith('channel:'):
if is_saved_chat_id(metadata.get('chat_id')):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@ -1632,7 +1655,17 @@ async def chat_completion(
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
try:
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(
chat_id, user.id
)
await asyncio.shield(
event_emitter(
{
'type': 'chat:active',
'data': {'active': False, 'folder_id': folder_id},
}
)
)
except asyncio.CancelledError:
pass
except Exception:
@ -1683,6 +1716,7 @@ async def chat_completion(
per_model_metadata = {
**metadata,
'message_id': assistant_message_id,
'task_id': str(uuid4()),
}
# Per-model form_data: own model
@ -1718,8 +1752,8 @@ async def chat_completion(
request.app.state.redis,
process,
id=chat_id,
task_id=per_model_metadata['task_id'],
)
per_model_metadata['task_id'] = task_id
task_ids.append(task_id)
if is_internal:
@ -1737,7 +1771,8 @@ async def chat_completion(
update_db=False,
)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id)
await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}})
return {
'status': True,
@ -1769,6 +1804,7 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion
from open_webui.utils.anthropic import (
convert_anthropic_to_openai_payload,
convert_openai_to_anthropic_response,
is_anthropic_messages_passthrough,
openai_stream_to_anthropic_stream,
)
@ -1783,6 +1819,65 @@ async def count_message_tokens(
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict:
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target(
request, form_data, user
)
request_url = f'{url.rstrip("/")}/messages'
response = None
streaming = False
try:
session = await get_session()
response = await session.request(
method='POST',
url=request_url,
data=json.dumps(payload),
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT),
)
if 'text/event-stream' in response.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(response),
status_code=response.status,
headers=openai._clean_proxy_headers(response.headers),
)
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
if response.status >= 400:
await openai.publish_model_provider_request_failed(
request,
actor=user,
provider='openai-compatible',
base_url=url,
api_key=key,
status=response.status,
requested_model=requested_model,
upstream_error=response_data,
)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=response.status, content=response_data)
return Response(status_code=response.status, content=response_data)
return response_data
except HTTPException:
raise
except Exception:
log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model)
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
finally:
if not streaming:
await cleanup_response(response)
@app.post('/api/message')
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
async def generate_messages(
@ -1823,11 +1918,20 @@ async def generate_messages(
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
url, _, api_config = await openai.get_openai_connection(model['urlIdx'])
if is_anthropic_messages_passthrough(url, api_config):
return await passthrough_anthropic_messages(request, form_data, user)
passthrough_params = api_config.get('passthrough_params') or []
# Convert Anthropic payload to OpenAI format
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
model_meta = model_info.meta.model_dump() if model_info and model_info.meta else {}
if (model_meta.get('capabilities') or {}).get('usage') is True:
if openai_payload.get('stream'):
stream_options = openai_payload.get('stream_options')
if not isinstance(stream_options, dict):
stream_options = {}
openai_payload['stream_options'] = {**stream_options, 'include_usage': True}
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)
@ -1836,9 +1940,7 @@ async def generate_messages(
if isinstance(response, StreamingResponse):
# Streaming response: wrap the generator to convert SSE format
return StreamingResponse(
openai_stream_to_anthropic_stream(
response.body_iterator, model=requested_model, input_tokens=input_tokens or 0
),
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model, input_tokens=input_tokens),
media_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
@ -1852,16 +1954,36 @@ async def generate_messages(
return response
async def verify_chat_ownership(chat_id: str | None, user) -> None:
"""Temporary chats are per-socket and unsaved, so they have no owner to check."""
if not chat_id or is_temporary_chat_id(chat_id):
return
# Channel messages need the membership and write-access gate that only /api/chat/completions has.
if chat_id.startswith('channel:'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Channel chats are not supported on this endpoint',
)
if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
@app.post('/api/chat/completed')
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
"""Deprecated: outlet filters now run inline during chat completion.
Kept for backward compatibility with external integrations."""
await verify_chat_ownership(form_data.get('chat_id'), user)
try:
model_item = form_data.pop('model_item', {})
if model_item.get('direct', False):
request.state.direct = True
request.state.model = model_item
await _set_direct_model(request, model_item, user)
return await chat_completed_handler(request, form_data, user)
except Exception as e:
@ -1873,12 +1995,13 @@ async def chat_completed(request: Request, form_data: dict, user=Depends(get_ver
@app.post('/api/chat/actions/{action_id}')
async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)):
await verify_chat_ownership(form_data.get('chat_id'), user)
try:
model_item = form_data.pop('model_item', {})
if model_item.get('direct', False):
request.state.direct = True
request.state.model = model_item
await _set_direct_model(request, model_item, user)
return await chat_action_handler(request, action_id, form_data, user)
except Exception as e:
@ -1904,8 +2027,8 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
@app.get('/api/tasks/chat/{chat_id:path}')
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
socket_id = get_temporary_chat_session_id(chat_id)
if socket_id:
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
return {'task_ids': []}
@ -1922,8 +2045,8 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
@app.post('/api/tasks/chat/{chat_id:path}/stop')
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
socket_id = get_temporary_chat_session_id(chat_id)
if socket_id:
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1975,6 +2098,7 @@ async def get_app_config(request: Request):
license_metadata = getattr(app.state, 'LICENSE_METADATA', None)
user_count = await Users.get_num_users() if license_metadata else None
config = await Config.get_many(
'oauth.enable',
'oauth.auto_redirect',
'ldap.enable',
'ui.enable_signup',
@ -2029,7 +2153,13 @@ async def get_app_config(request: Request):
'version': VERSION,
'default_locale': str(DEFAULT_LOCALE),
'oauth': {
'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()},
# Hide providers (and thus the login buttons / auto-redirect) when OAuth
# is disabled, without clearing the admin's provider configuration.
'providers': (
{name: provider.get('name', name) for name, provider in OAUTH_PROVIDERS.items()}
if config.get('oauth.enable', True)
else {}
),
'auto_redirect': config.get('oauth.auto_redirect'),
},
'features': {

View file

@ -0,0 +1,54 @@
"""add automation folder id
Revision ID: 959eaac8f909
Revises: 55f1302ac17c
Create Date: 2026-07-26 19:19:31.345756
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = '959eaac8f909'
down_revision: str | None = '55f1302ac17c'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'folder_id' not in columns:
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
if 'ix_automation_user_folder' not in indexes:
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index('ix_automation_user_folder', table_name='automation')
op.drop_column('automation', 'folder_id')
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'ix_automation_user_folder' in indexes:
op.drop_index('ix_automation_user_folder', table_name='automation')
if 'folder_id' in columns:
op.drop_column('automation', 'folder_id')

View file

@ -5,6 +5,7 @@ Revises: c49178636c78
Create Date: 2026-07-24 01:21:46.457057
"""
from typing import Sequence, Union
import sqlalchemy as sa

View file

@ -5,6 +5,7 @@ Revises: 9a1b2c3d4e5f
Create Date: 2026-07-23 23:33:45.497453
"""
from typing import Sequence, Union
import sqlalchemy as sa

View file

@ -11,6 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
PRINCIPAL_TYPE_ANYONE = 'anyone'
PRINCIPAL_TYPE_GROUP = 'group'
PRINCIPAL_TYPE_USER = 'user'
WILDCARD_PRINCIPAL_ID = '*'
####################
# AccessGrant DB Schema
@ -23,7 +28,7 @@ class AccessGrant(Base):
id = Column(Text, primary_key=True)
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_type = Column(Text, nullable=False) # "user", "group", or "anyone"
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
@ -163,12 +168,14 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
principal_id = grant.get('principal_id')
permission = grant.get('permission')
if principal_type not in ('user', 'group'):
if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE):
continue
if permission not in ('read', 'write'):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'):
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
@ -186,7 +193,11 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
return True
return False
@ -196,7 +207,25 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-write.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'write'
):
return True
return False
def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes no-auth anyone-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant['principal_type'] == PRINCIPAL_TYPE_ANYONE
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
return True
return False
@ -206,7 +235,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID:
return True
return False
@ -223,12 +252,27 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
for grant in access_grants
if not (
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
== PRINCIPAL_TYPE_USER
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
!= WILDCARD_PRINCIPAL_ID
)
]
def strip_anyone_access_grants(access_grants: Optional[list]) -> list:
"""
Remove no-auth anyone grants from the list.
"""
if not access_grants:
return []
return [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= PRINCIPAL_TYPE_ANYONE
]
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
@ -493,6 +537,28 @@ class AccessGrantsTable:
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
return result_dict
async def has_anyone_access(
self,
resource_type: str,
resource_id: str,
permission: str = 'read',
db: Optional[AsyncSession] = None,
) -> bool:
"""Check for a no-auth anyone:* grant. Callers must opt in explicitly."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE,
AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID,
AccessGrant.permission == permission,
)
.limit(1)
)
return result.scalars().first() is not None
async def has_access(
self,
user_id: str,

View file

@ -21,6 +21,7 @@ class Automation(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
folder_id = Column(Text, nullable=True)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
@ -31,7 +32,10 @@ class Automation(Base):
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
__table_args__ = (
Index('ix_automation_next_run', 'next_run_at'),
Index('ix_automation_user_folder', 'user_id', 'folder_id'),
)
class AutomationRun(Base):
@ -72,6 +76,7 @@ class AutomationModel(BaseModel):
id: str
user_id: str
folder_id: Optional[str] = None
name: str
data: dict
meta: Optional[dict] = None
@ -96,6 +101,7 @@ class AutomationRunModel(BaseModel):
class AutomationForm(BaseModel):
name: str
folder_id: Optional[str] = None
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
@ -129,6 +135,7 @@ class AutomationTable:
row = Automation(
id=str(uuid4()),
user_id=user_id,
folder_id=form.folder_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
@ -164,6 +171,7 @@ class AutomationTable:
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
folder_id: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
@ -171,6 +179,9 @@ class AutomationTable:
async with get_async_db_context(db) as db:
stmt = select(Automation).filter_by(user_id=user_id)
if folder_id is not None:
stmt = stmt.filter(Automation.folder_id == (folder_id or None))
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
@ -216,6 +227,7 @@ class AutomationTable:
if not row:
return None
row.name = form.name
row.folder_id = form.folder_id
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
@ -225,6 +237,23 @@ class AutomationTable:
await db.commit()
return AutomationModel.model_validate(row)
async def clear_folder_ids(
self,
user_id: str,
folder_ids: list[str],
db: Optional[AsyncSession] = None,
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as db:
result = await db.execute(
update(Automation)
.where(Automation.user_id == user_id, Automation.folder_id.in_(folder_ids))
.values(folder_id=None, updated_at=int(time.time_ns()))
)
await db.commit()
return result.rowcount or 0
async def toggle(
self,
id: str,

View file

@ -241,11 +241,11 @@ class CalendarTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarModel:
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
cal_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
calendar_model = CalendarModel.model_validate(cal)
calendar_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(calendar_model.id, db=db)
)
return CalendarModel.model_validate(cal_data)
return calendar_model
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Return user's calendars, creating 'Personal' default if none exist."""
@ -777,9 +777,7 @@ class CalendarEventAttendeeTable:
existing_status = {
row.user_id: row.status
for row in (
await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)
)
await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
).scalars()
}

View file

@ -266,11 +266,11 @@ class ChannelTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={'access_grants'})
channel_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(channel_data['id'], db=db)
channel_model = ChannelModel.model_validate(channel)
channel_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(channel_model.id, db=db)
)
return ChannelModel.model_validate(channel_data)
return channel_model
async def _collect_unique_user_ids(
self,

View file

@ -27,6 +27,7 @@ from sqlalchemy import (
UniqueConstraint,
and_,
delete,
exists,
func,
or_,
select,
@ -35,13 +36,37 @@ from sqlalchemy import (
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.sql import exists
from sqlalchemy.sql import case, exists
from sqlalchemy.sql.expression import bindparam
log = logging.getLogger(__name__)
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None):
if sort_by != 'unread_updated_at':
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
return order_clause, Chat.id
unfinished_assistant = (
select(ChatMessage.id)
.where(ChatMessage.chat_id == Chat.id)
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
.exists()
)
conditions = [Chat.updated_at > func.coalesce(Chat.last_read_at, 0), ~unfinished_assistant]
if user_id is not None:
conditions.append(Chat.user_id == user_id)
unread = case(
(and_(*conditions), 1),
else_=0,
)
return unread.desc(), Chat.updated_at.desc(), Chat.id
class Chat(Base): # database table mapping for chat entity
__tablename__ = 'chat'
@ -538,7 +563,7 @@ class ChatTable:
await session.commit()
# Dual-write messages to chat_message table
for form_data, chat_obj in zip(chat_import_forms, chats):
for form_data, imported_chat in zip(chat_import_forms, chats):
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(form_data.chat.get('messages'), list):
@ -552,12 +577,14 @@ class ChatTable:
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_obj.id,
chat_id=imported_chat.id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}')
log.warning(
f'Failed to write imported message {message_id} for chat {imported_chat.id}: {e}'
)
return [ChatModel.model_validate(chat) for chat in chats]
@ -613,17 +640,75 @@ class ChatTable:
except Exception:
return None
async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
async def update_chat_last_read_at_by_id(
self, id: str, user_id: str, db: AsyncSession | None = None
) -> tuple[int, bool] | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
if chat and chat.user_id == user_id:
chat.last_read_at = int(time.time())
last_read_at = int(time.time())
was_unread = chat.last_read_at is None or chat.updated_at > chat.last_read_at
chat.last_read_at = last_read_at
await session.commit()
return True
return False
return last_read_at, was_unread
return None
except Exception:
return False
return None
async def mark_chat_unread_by_id(
self, id: str, user_id: str, db: AsyncSession | None = None
) -> ChatTitleIdResponse | None:
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
if chat and chat.user_id == user_id:
chat.last_read_at = 0
await session.commit()
return ChatTitleIdResponse(
id=chat.id,
title=chat.title,
updated_at=chat.updated_at,
created_at=chat.created_at,
last_read_at=chat.last_read_at,
)
return None
except Exception:
return None
async def mark_chats_read_by_folder_ids(
self, user_id: str, folder_ids: list[str], db: AsyncSession | None = None
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as session:
result = await session.execute(
update(Chat)
.where(
Chat.user_id == user_id,
Chat.folder_id.in_(folder_ids),
Chat.archived == False,
Chat.meta['internal'].as_boolean().is_not(True),
)
.values(last_read_at=Chat.updated_at)
)
await session.commit()
return result.rowcount or 0
async def mark_chats_read_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(
update(Chat)
.where(
Chat.user_id == user_id,
Chat.archived == False,
Chat.meta['internal'].as_boolean().is_not(True),
)
.values(last_read_at=Chat.updated_at)
)
await session.commit()
return result.rowcount or 0
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
try:
@ -639,30 +724,32 @@ class ChatTable:
except Exception:
return None
async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> ChatModel | None:
async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> None:
"""Replace a chat's tags. Runs after every completion with tag
generation enabled, so only the meta column is read and written,
never the chat blob."""
async with get_async_db_context() as session:
chat = await session.get(Chat, id)
if chat is None:
row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none()
if row is None:
return None
old_tags = chat.meta.get('tags', [])
meta = row[0] or {}
old_tags = meta.get('tags', [])
new_tags = [t for t in tags if t.replace(' ', '_').lower() != 'none']
new_tag_ids = [t.replace(' ', '_').lower() for t in new_tags]
# Single meta update
chat.meta = {**chat.meta, 'tags': new_tag_ids}
await session.execute(update(Chat).filter_by(id=id).values(meta={**meta, 'tags': new_tag_ids}))
await session.commit()
# Batch-create any missing tag rows
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
# Clean up orphaned old tags in one query
# Clean up orphaned old tags
removed = set(old_tags) - set(new_tag_ids)
if removed:
await self.delete_orphan_tags_for_user(list(removed), user.id, db=session)
return ChatModel.model_validate(chat)
async def get_chat_title_by_id(self, id: str) -> str | None:
async with get_async_db_context() as session:
result = await session.execute(select(Chat.title).filter_by(id=id))
@ -745,6 +832,54 @@ class ChatTable:
history['currentId'] = current_id if current_id in messages else None
return deleted_ids
@staticmethod
def upsert_message_to_history(history: dict, message_id: str, message: dict) -> dict:
messages = history.setdefault('messages', {})
if message_id in messages:
messages[message_id] = {
**messages[message_id],
**message,
}
else:
message_parent_id = message.get('parentId')
parent_id = message_parent_id
if parent_id is None:
for existing_id, existing_message in messages.items():
if message_id in existing_message.get('childrenIds', []):
parent_id = existing_id
break
parent = messages.get(parent_id) if parent_id else None
output = message.get('output') or []
output_role = next(
(item.get('role') for item in output if isinstance(item, dict) and item.get('role')),
None,
)
role = message.get('role') or output_role
if not role:
parent_role = parent.get('role') if parent else None
if parent_role == 'user':
role = 'assistant'
elif parent_role == 'assistant':
role = 'user'
else:
role = 'assistant'
messages[message_id] = {
**message,
'id': message.get('id') or message_id,
'parentId': message_parent_id if message_parent_id is not None else parent_id,
'childrenIds': (
message.get('childrenIds') if isinstance(message.get('childrenIds'), list) else []
),
'role': role,
'timestamp': message.get('timestamp') or int(time.time()),
}
history['currentId'] = message_id
return messages[message_id]
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Write messages to the ``chat_message`` table so future lookups
use the fast path. Errors are logged but never raised.
@ -836,10 +971,6 @@ class ChatTable:
async def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict, *, touch: bool = True
) -> ChatModel | None:
chat = await self.get_chat_by_id(id)
if chat is None:
return None
if not message.get('content'):
output_text = get_output_text(message.get('output'))
if output_text:
@ -849,105 +980,121 @@ class ChatTable:
if isinstance(message.get('content'), str):
message['content'] = sanitize_text_for_db(message['content'])
user_id = chat.user_id
chat = chat.chat
history = chat.get('history', {})
messages = history.setdefault('messages', {})
if message_id in messages:
messages[message_id] = {
**messages[message_id],
**message,
}
else:
message_parent_id = message.get('parentId')
parent_id = message_parent_id
if parent_id is None:
for existing_id, existing_message in messages.items():
if message_id in existing_message.get('childrenIds', []):
parent_id = existing_id
break
parent = messages.get(parent_id) if parent_id else None
output = message.get('output') or []
output_role = next(
(item.get('role') for item in output if isinstance(item, dict) and item.get('role')),
None,
)
role = message.get('role') or output_role
if not role:
parent_role = parent.get('role') if parent else None
if parent_role == 'user':
role = 'assistant'
elif parent_role == 'assistant':
role = 'user'
else:
role = 'assistant'
messages[message_id] = {
**message,
'id': message.get('id') or message_id,
'parentId': message_parent_id if message_parent_id is not None else parent_id,
'childrenIds': message.get('childrenIds') if isinstance(message.get('childrenIds'), list) else [],
'role': role,
'timestamp': message.get('timestamp') or int(time.time()),
}
history['currentId'] = message_id
chat['history'] = history
# Dual-write to chat_message table
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=messages[message_id],
)
except Exception as e:
log.warning(f'Failed to write to chat_message table: {e}')
async with get_async_db_context() as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
return await self.update_chat_by_id(id, chat, touch=touch)
self._sanitize_chat_row(chat_item)
chat = chat_item.chat or {}
self._repair_chat_current_id(chat)
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
chat_model = await self.get_chat_by_id(id)
if chat_model is None:
history = chat.get('history', {})
saved_message = self.upsert_message_to_history(history, message_id, message)
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
flag_modified(chat_item, 'chat')
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
updated_chat = ChatModel.model_validate(chat_item)
user_id = chat_item.user_id
# Dual-write to chat_message table
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=saved_message,
)
except Exception as e:
log.warning(f'Failed to write to chat_message table: {e}')
return updated_chat
except Exception:
return None
chat = chat_model.chat
history = chat.get('history', {})
deleted_ids = self.delete_message_from_history(history, message_id)
if not deleted_ids:
return chat_model
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
try:
async with get_async_db_context() as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
messages = history.get('messages') or {}
chat['history'] = history
updated_chat = await self.update_chat_by_id(id, chat)
self._sanitize_chat_row(chat_item)
chat = chat_item.chat or {}
self._repair_chat_current_id(chat)
await self.backfill_messages_by_chat_id(id, chat_model.user_id, messages)
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
history = chat.get('history', {})
deleted_ids = self.delete_message_from_history(history, message_id)
if not deleted_ids:
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = (
self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
)
chat_item.current_message_id = self.get_current_message_id(clean_chat)
flag_modified(chat_item, 'chat')
await session.commit()
return ChatModel.model_validate(chat_item)
return updated_chat
messages = history.get('messages') or {}
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
flag_modified(chat_item, 'chat')
chat_item.updated_at = int(time.time())
await session.commit()
updated_chat = ChatModel.model_validate(chat_item)
user_id = chat_item.user_id
await self.backfill_messages_by_chat_id(id, user_id, messages)
await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids)
return updated_chat
except Exception:
return None
async def add_message_status_to_chat_by_id_and_message_id(
self, id: str, message_id: str, status: dict
) -> ChatModel | None:
chat = await self.get_chat_by_id(id)
if chat is None:
try:
async with get_async_db_context() as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
self._sanitize_chat_row(chat_item)
chat = chat_item.chat or {}
self._repair_chat_current_id(chat)
history = chat.get('history', {})
if message_id in history.get('messages', {}):
status_history = history['messages'][message_id].get('statusHistory', [])
status_history.append(status)
history['messages'][message_id]['statusHistory'] = status_history
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
flag_modified(chat_item, 'chat')
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return None
chat = chat.chat
history = chat.get('history', {})
if message_id in history.get('messages', {}):
status_history = history['messages'][message_id].get('statusHistory', [])
status_history.append(status)
history['messages'][message_id]['statusHistory'] = status_history
chat['history'] = history
return await self.update_chat_by_id(id, chat, touch=False)
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
async with get_async_db_context() as session:
chat = await self.get_chat_by_id(id, db=session)
@ -1210,6 +1357,8 @@ class ChatTable:
include_archived: bool = False,
include_folders: bool = False,
include_pinned: bool = False,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
@ -1229,7 +1378,7 @@ class ChatTable:
if not include_archived:
stmt = stmt.filter_by(archived=False)
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
if skip:
stmt = stmt.offset(skip)
@ -1435,6 +1584,37 @@ class ChatTable:
except Exception:
return None
async def count_unread_by_folder_ids(
self,
user_id: str,
folder_ids: list[str],
db: AsyncSession | None = None,
) -> dict[str, int]:
if not folder_ids:
return {}
unfinished_assistant = (
select(ChatMessage.id)
.where(ChatMessage.chat_id == Chat.id)
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
.exists()
)
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat.folder_id, func.count(Chat.id))
.where(
Chat.user_id == user_id,
Chat.folder_id.in_(folder_ids),
Chat.archived == False,
Chat.updated_at > func.coalesce(Chat.last_read_at, 0),
~unfinished_assistant,
)
.group_by(Chat.folder_id)
)
return {folder_id: count for folder_id, count in result.all() if folder_id}
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
@ -1769,6 +1949,8 @@ class ChatTable:
user_id: str,
skip: int = 0,
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession | None = None,
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
@ -1778,8 +1960,8 @@ class ChatTable:
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
)
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
if skip:
stmt = stmt.offset(skip)
@ -1808,20 +1990,19 @@ class ChatTable:
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
unread_for_user_id: str | None = None,
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(order_clause, Chat.id)
)
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir, unread_for_user_id))
if skip:
stmt = stmt.offset(skip)
@ -1885,6 +2066,11 @@ class ChatTable:
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
chat.pinned = False
if folder_id is not None:
# Folder listings only show unarchived chats, so moving an archived
# chat into a folder would otherwise have no visible effect: the chat
# stays in the archived list and never appears in the folder.
chat.archived = False
await session.commit()
return ChatModel.model_validate(chat)
except Exception:
@ -1953,45 +2139,66 @@ class ChatTable:
async def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
) -> ChatModel | None:
) -> None:
"""Add one tag to a chat's meta. Meta-column-only, never the blob."""
tag_id = tag_name.replace(' ', '_').lower()
await Tags.ensure_tags_exist([tag_name], user_id, db=db)
try:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
if tag_id not in chat.meta.get('tags', []):
chat.meta = {
**chat.meta,
'tags': list(set(chat.meta.get('tags', []) + [tag_id])),
}
await session.commit()
return ChatModel.model_validate(chat)
row = (await session.execute(select(Chat.meta).filter_by(id=id))).one_or_none()
if row is None:
return None
meta = row[0] or {}
if tag_id not in meta.get('tags', []):
await session.execute(
update(Chat)
.filter_by(id=id)
.values(meta={**meta, 'tags': list(set(meta.get('tags', []) + [tag_id]))})
)
await session.commit()
except Exception:
return None
async def count_chats_by_tag_name_and_user_id(
self, tag_name: str, user_id: str, db: AsyncSession | None = None
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
tag_id = tag_name.replace(' ', '_').lower()
counts = await self.count_chats_by_tag_ids_and_user_id([tag_id], user_id, db=db)
return counts.get(tag_id, 0)
async def count_chats_by_tag_ids_and_user_id(
self, tag_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> dict[str, int]:
"""Per-tag chat counts in one round trip (one scalar subquery per tag)."""
if not tag_ids:
return {}
async with get_async_db_context(db) as session:
bind = await session.connection()
dialect_name = bind.dialect.name
if dialect_name == 'sqlite':
stmt = stmt.filter(
text("EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)")
).params(tag_id=tag_id)
elif dialect_name == 'postgresql':
stmt = stmt.filter(
text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)")
).params(tag_id=tag_id)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
result = await session.execute(stmt)
return result.scalar()
columns = []
for index, tag_id in enumerate(tag_ids):
tag_id = tag_id.replace(' ', '_').lower()
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
param = f'tag_id_{index}'
if dialect_name == 'sqlite':
stmt = stmt.filter(
text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :{param})")
).params(**{param: tag_id})
elif dialect_name == 'postgresql':
stmt = stmt.filter(
text(
f"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :{param})"
)
).params(**{param: tag_id})
else:
raise NotImplementedError(f'Unsupported dialect: {dialect_name}')
columns.append(stmt.scalar_subquery().label(f'count_{index}'))
row = (await session.execute(select(*columns))).one()
return dict(zip(tag_ids, row))
async def delete_orphan_tags_for_user(
self,
@ -2011,11 +2218,8 @@ class ChatTable:
if not tag_ids:
return
async with get_async_db_context(db) as session:
orphans = []
for tag_id in tag_ids:
count = await self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=session)
if count <= threshold:
orphans.append(tag_id)
counts = await self.count_chats_by_tag_ids_and_user_id(tag_ids, user_id, db=session)
orphans = [tag_id for tag_id in tag_ids if counts.get(tag_id, 0) <= threshold]
await Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=session)
async def count_chats_by_folder_id_and_user_id(

View file

@ -46,6 +46,7 @@ API_CONFIG_FIELDS = (
'auth_type',
'headers',
'azure',
'api_type',
'api_version',
'extra_params',
'passthrough_params',

View file

@ -58,6 +58,7 @@ class FolderNameIdResponse(BaseModel):
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
unread_count: int = 0
created_at: int
updated_at: int

View file

@ -36,6 +36,9 @@ from sqlalchemy.orm import defer
log = logging.getLogger(__name__)
# Columns the knowledge base list may be ordered by; anything else falls back to the default.
KNOWLEDGE_SORTABLE_FIELDS = {'name', 'created_at', 'updated_at'}
####################
# Knowledge DB Schema
# Let what was gathered here outlast the one who gathered it,
@ -192,11 +195,11 @@ class KnowledgeTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'})
knowledge_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_data['id'], db=db)
knowledge_model = KnowledgeModel.model_validate(knowledge)
knowledge_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_model.id, db=db)
)
return KnowledgeModel.model_validate(knowledge_data)
return knowledge_model
async def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None
@ -309,7 +312,17 @@ class KnowledgeTable:
permission='read',
)
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
order_by = (filter or {}).get('order_by')
direction = (filter or {}).get('direction')
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
column = getattr(Knowledge, order_by)
if (direction or 'desc').lower() == 'asc':
stmt = stmt.order_by(column.asc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(column.desc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()

View file

@ -199,11 +199,11 @@ class ModelsTable:
if db is not None:
await db.commit()
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
model_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
model_model = ModelModel.model_validate(model)
model_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(model_model.id, db=db)
)
return ModelModel.model_validate(model_data)
return model_model
async def insert_new_model(
self, form_data: ModelForm, user_id: str, db: AsyncSession | None = None
@ -555,12 +555,12 @@ class ModelsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter_by(id=id))
model_obj = result.scalars().first()
if not model_obj:
model = result.scalars().first()
if not model:
return None
model_obj.updated_at = int(time.time())
model.updated_at = int(time.time())
await db.commit()
return await self._to_model_model(model_obj, db=db)
return await self._to_model_model(model, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
return None

View file

@ -106,11 +106,12 @@ class NoteTable:
db: Optional[AsyncSession] = None,
) -> NoteModel:
# We exclude access_grants to inject them
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db)
note_model = NoteModel.model_validate(note)
note_model.data = note_model.data or {}
note_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(note_model.id, db=db)
)
return NoteModel.model_validate(note_data)
return note_model
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
@ -308,9 +309,9 @@ class NoteTable:
if 'title' in form_data:
note.title = form_data['title']
if 'data' in form_data:
note.data = {**note.data, **form_data['data']}
note.data = {**(note.data or {}), **(form_data['data'] or {})}
if 'meta' in form_data:
note.meta = {**note.meta, **form_data['meta']}
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note) and 'access_grants' not in form_data:
return await self._to_note_model(note, db=db)

View file

@ -103,11 +103,11 @@ class PromptsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'})
prompt_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_data['id'], db=db)
prompt_model = PromptModel.model_validate(prompt)
prompt_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_model.id, db=db)
)
return PromptModel.model_validate(prompt_data)
return prompt_model
async def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None

View file

@ -113,11 +113,11 @@ class SkillsTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'})
skill_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(skill_data['id'], db=db)
skill_model = SkillModel.model_validate(skill)
skill_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(skill_model.id, db=db)
)
return SkillModel.model_validate(skill_data)
return skill_model
async def insert_new_skill(
self,

View file

@ -106,11 +106,11 @@ class ToolsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={'access_grants'})
tool_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(tool_data['id'], db=db)
tool_model = ToolModel.model_validate(tool)
tool_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(tool_model.id, db=db)
)
return ToolModel.model_validate(tool_data)
return tool_model
async def insert_new_tool(
self,

View file

@ -4,6 +4,7 @@ import re
import time
from typing import Any, Optional
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.models.config import Config
from open_webui.models.knowledge import KnowledgeModel
@ -103,7 +104,7 @@ async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, emb
source_config = _source_config(knowledge)
vector_field = source_config.get('vector_field') or None
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client = QdrantClient(
@ -152,7 +153,7 @@ async def _retrieve_milvus(connection, auth_config, knowledge, query, count, emb
content_field = source_config.get('content_field') or 'data.text'
metadata_field = source_config.get('metadata_field') or 'metadata'
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client_kwargs = {
@ -229,7 +230,7 @@ async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, e
metadata_field = source_config.get('metadata_field') or 'vmetadata'
document_id_field = source_config.get('document_id_field') or 'id'
vector = await embedding_function(query)
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
from psycopg import sql

View file

@ -6,7 +6,7 @@ from urllib.parse import quote
import requests
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.headers import include_user_info_headers, parse_custom_headers
log = logging.getLogger(__name__)
@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader):
api_key: str,
mime_type=None,
user=None,
user_groups=None,
headers=None,
metadata=None,
**kwargs,
@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader):
self.mime_type = mime_type
self.user = user
self.user_groups = user_groups
self.headers = headers
self.metadata = metadata
@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader):
except Exception:
pass
headers.update(get_custom_headers(self.headers, self.user, self.metadata))
headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups))
if self.user is not None:
headers = include_user_info_headers(headers, self.user)

View file

@ -11,7 +11,6 @@ from langchain_community.document_loaders import (
BSHTMLLoader,
CSVLoader,
Docx2txtLoader,
OutlookMessageLoader,
PyPDFLoader,
TextLoader,
YoutubeLoader,
@ -27,7 +26,8 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader
from open_webui.utils.headers import get_user_groups_for_custom_headers
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -206,6 +206,9 @@ class DoclingLoader:
data={
'image_export_mode': 'placeholder',
'md_page_break_placeholder': page_break_marker,
# Keep Docling params as user-provided form values. Encoding nested
# values here would make Open WebUI responsible for Docling's API
# quirks and could break when Docling changes its form contract.
**self.params,
},
headers=headers,
@ -246,6 +249,7 @@ class Loader:
def __init__(self, engine: str = '', **kwargs):
self.engine = engine
self.user = kwargs.get('user', None)
self.user_groups = kwargs.get('user_groups', None)
self.metadata = kwargs.get('metadata', {})
self.kwargs = kwargs
@ -264,6 +268,13 @@ class Loader:
loop for the entire parse — minutes for large PDFs. This offloads
the work to a worker thread so the loop stays responsive.
"""
# Group lookup is async-only, so it must happen before `load`
# is offloaded to a thread without a running event loop.
if self.engine == 'external' and self.user_groups is None:
self.user_groups = await get_user_groups_for_custom_headers(
self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user
)
return await asyncio.to_thread(self.load, filename, file_content_type, file_path)
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
@ -303,13 +314,20 @@ class Loader:
try:
raw.decode('utf-8')
return 'utf-8'
except UnicodeDecodeError:
pass
except UnicodeDecodeError as e:
first_non_utf8 = e.start
# Use chardet as a hint, not as ground truth
import chardet
detected = chardet.detect(raw)
# chardet is pure Python (~1.3s/MB), so sample around the first bad byte
window = 256 * 1024
sample_start = max(0, first_non_utf8 - window // 2)
sample = raw[sample_start : sample_start + window]
detected = chardet.detect(sample)
# A stray byte can sit far from the real payload, leaving the sample with nothing to read
if len(sample.translate(None, delete=bytes(range(128)))) < 64 and len(sample) < len(raw):
detected = chardet.detect(raw)
detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
# Map chardet's detected encoding to the correct superset codec.
@ -422,6 +440,7 @@ class Loader:
api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'),
mime_type=file_content_type,
user=self.user,
user_groups=self.user_groups,
headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'),
metadata={
**self.metadata,
@ -556,7 +575,12 @@ class Loader:
use_base64=self.kwargs.get('MISTRAL_OCR_USE_BASE64', False),
user=self.user,
)
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
elif (
self.engine == 'paddleocr_vl'
and self.kwargs.get('PADDLEOCR_VL_BASE_URL')
and self.kwargs.get('PADDLEOCR_VL_TOKEN')
and file_ext in PADDLEOCR_VL_SUPPORTED_EXTENSIONS
):
loader = PaddleOCRVLLoader(
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
@ -655,7 +679,18 @@ class Loader:
)
loader = PptxLoader(file_path)
elif file_ext == 'msg':
loader = OutlookMessageLoader(file_path)
try:
from langchain_community.document_loaders import (
UnstructuredEmailLoader,
)
# unstructured parses .msg via python-oxmsg; avoids extract_msg's beautifulsoup4<4.14 conflict
loader = UnstructuredEmailLoader(file_path, process_attachments=False)
except ImportError:
raise ValueError(
"Processing .msg files requires the 'unstructured' package. "
'Install it with: pip install unstructured'
)
elif file_ext == 'odt':
try:
from langchain_community.document_loaders import UnstructuredODTLoader

View file

@ -11,6 +11,9 @@ from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
PADDLEOCR_VL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
PADDLEOCR_VL_SUPPORTED_EXTENSIONS = ['pdf'] + PADDLEOCR_VL_IMAGE_EXTENSIONS
class PaddleOCRVLLoader:
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
@ -46,8 +49,7 @@ class PaddleOCRVLLoader:
# Detect fileType based on file extension
ext = self.file_path.lower().split('.')[-1]
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
file_type = 1 if ext in image_extensions else 0
file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0
payload = {
'file': file_data,

View file

@ -632,7 +632,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = hashlib.sha256(document.encode()).hexdigest() # Compute a hash for uniqueness
doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document)
if doc_hash not in combined.keys():
combined[doc_hash] = (distance, document, metadata)
@ -1267,7 +1267,7 @@ async def filter_accessible_collections(
- any name with characters outside [A-Za-z0-9_-] → rejected
- file-* → validated via has_access_to_file
- user-memory-* → must match user's own memory collection
- web-search-* → ephemeral per-query collections, always allowed
- web-search-* → ephemeral per-query collections, owner-bound to web-search-{user.id}-*
- knowledge-bases → always denied (system meta-collection)
- everything else → if the name matches a knowledge base, validated
via Knowledges.check_access_by_user_id; if no
@ -1302,10 +1302,10 @@ async def filter_accessible_collections(
if name == f'user-memory-{user.id}':
validated.add(name)
elif name.startswith('web-search-'):
# Ephemeral collections created by process_web_search — safe
# to allow because they contain only transient web-search
# results scoped to the requesting user's session.
validated.add(name)
# Ephemeral per-query collections, owner-bound: process_web_search mints
# them as web-search-{user.id}-<hash>, so only the creator may read/write.
if name.startswith(f'web-search-{user.id}-'):
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
@ -1335,7 +1335,7 @@ async def get_sources_from_items(
full_context=False,
user: UserModel | None = None,
):
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
log.debug('items: %s %s %s %s %s', items, queries, embedding_function, reranking_function, full_context)
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
extracted_collections = []

View file

@ -3,6 +3,7 @@ from typing import Optional
import chromadb
from chromadb import Settings
from chromadb.errors import NotFoundError
from chromadb.utils.batch_utils import create_batches
from open_webui.config import (
CHROMA_CLIENT_AUTH_CREDENTIALS,
@ -56,13 +57,11 @@ class ChromaClient(VectorDBBase):
)
def has_collection(self, collection_name: str) -> bool:
# Check if the collection exists based on the collection name.
# chromadb's list_collections() returns Collection objects (1.x), so a
# bare `name in collections` membership test is always False — compare
# against the names. (hasattr guard tolerates versions that yield names.)
collections = self.client.list_collections()
collection_names = [c.name if hasattr(c, 'name') else c for c in collections]
return collection_name in collection_names
try:
self.client.get_collection(name=collection_name)
return True
except NotFoundError:
return False
def delete_collection(self, collection_name: str):
# Delete the collection based on the collection name.

View file

@ -25,7 +25,7 @@ from open_webui.retrieval.vector.main import (
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from pymilvus import Collection, DataType, FieldSchema, connections
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
@ -202,8 +202,6 @@ class MilvusClient(VectorDBBase):
return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
collection_name = collection_name.replace('-', '_')
if not self.has_collection(collection_name):
log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}')
@ -218,16 +216,16 @@ class MilvusClient(VectorDBBase):
filter_string = ' && '.join(filter_expressions)
collection = Collection(f'{self.collection_prefix}_{collection_name}')
collection.load()
self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}')
try:
log.info(
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
)
iterator = collection.query_iterator(
expr=filter_string,
iterator = self.client.query_iterator(
collection_name=f'{self.collection_prefix}_{collection_name}',
filter=filter_string,
output_fields=[
'id',
'data',

View file

@ -23,14 +23,8 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from pymilvus import (
Collection,
CollectionSchema,
DataType,
FieldSchema,
connections,
utility,
)
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
log = logging.getLogger(__name__)
@ -70,12 +64,7 @@ class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_')
connections.connect(
alias='default',
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories'
@ -116,63 +105,66 @@ class MilvusClient(VectorDBBase):
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
fields = [
FieldSchema(
name='id',
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name='metadata', dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, 'Shared collection for multi-tenancy')
collection = Collection(mt_collection_name, schema)
schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy')
schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36)
schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension)
schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH)
schema.add_field(field_name='metadata', datatype=DataType.JSON)
schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255)
index_params = {
'metric_type': MILVUS_METRIC_TYPE,
'index_type': MILVUS_INDEX_TYPE,
'params': {},
}
index_build_params = {}
if MILVUS_INDEX_TYPE == 'HNSW':
index_params['params'] = {
index_build_params = {
'M': MILVUS_HNSW_M,
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == 'IVF_FLAT':
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
collection.create_index('vector', index_params)
vector_index = self.client.prepare_index_params(
field_name='vector',
index_type=MILVUS_INDEX_TYPE,
metric_type=MILVUS_METRIC_TYPE,
params=index_build_params,
)
self.client.create_collection(collection_name=mt_collection_name, schema=schema)
self.client.create_index(collection_name=mt_collection_name, index_params=vector_index)
try:
# A Milvus server auto-selects the scalar index type; embedded
# Milvus Lite requires an explicit one.
collection.create_index(RESOURCE_ID_FIELD)
# A Milvus server auto-selects the scalar index type from a parameterless call.
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD),
)
except MilvusException:
try:
collection.create_index(RESOURCE_ID_FIELD, {'index_type': 'INVERTED'})
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD, index_type='INVERTED'),
)
except MilvusException as e:
# The index only accelerates resource_id filters; never fail
# collection creation over it.
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
log.info(f'Created shared collection: {mt_collection_name}')
return collection
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not utility.has_collection(mt_collection_name):
if not self.client.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return False
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
self.client.load_collection(mt_collection)
res = self.client.query(
collection_name=mt_collection,
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id'],
limit=1,
)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
@ -182,7 +174,6 @@ class MilvusClient(VectorDBBase):
_validate_resource_id(resource_id)
dimension = len(items[0]['vector'])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = []
for item in items:
@ -205,7 +196,7 @@ class MilvusClient(VectorDBBase):
)
try:
collection.insert(entities)
self.client.insert(collection_name=mt_collection, data=entities)
except MilvusException as e:
log.error(
f'Milvus insert failed (collection={mt_collection}, '
@ -225,19 +216,18 @@ class MilvusClient(VectorDBBase):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
self.client.load_collection(mt_collection)
search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}}
results = collection.search(
results = self.client.search(
collection_name=mt_collection,
data=vectors,
anns_field='vector',
param=search_params,
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
limit=limit,
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id', 'text', 'metadata'],
)
@ -245,10 +235,11 @@ class MilvusClient(VectorDBBase):
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
batch_ids.append(hit.entity.get('id'))
batch_docs.append(hit.entity.get('text'))
batch_metadatas.append(hit.entity.get('metadata'))
batch_dists.append(hit.distance)
entity = hit.get('entity', {})
batch_ids.append(entity.get('id'))
batch_docs.append(entity.get('text'))
batch_metadatas.append(entity.get('metadata'))
batch_dists.append(hit.get('distance'))
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
@ -264,11 +255,9 @@ class MilvusClient(VectorDBBase):
):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return
collection = Collection(mt_collection)
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
@ -280,30 +269,28 @@ class MilvusClient(VectorDBBase):
_validate_metadata_key(key)
expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'")
collection.delete(' and '.join(expr))
self.client.delete(collection_name=mt_collection, filter=' and '.join(expr))
def reset(self):
for collection_name in self.shared_collections:
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
if self.client.has_collection(collection_name):
self.client.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
self.client.delete(collection_name=mt_collection, filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not utility.has_collection(mt_collection):
if not self.client.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
self.client.load_collection(mt_collection)
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
@ -318,8 +305,9 @@ class MilvusClient(VectorDBBase):
else:
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
iterator = collection.query_iterator(
expr=' and '.join(expr),
iterator = self.client.query_iterator(
collection_name=mt_collection,
filter=' and '.join(expr),
output_fields=['id', 'text', 'metadata'],
limit=limit if limit else -1,
)

View file

@ -28,10 +28,10 @@ def build_firecrawl_url(base_url: str | None, path: str) -> str:
def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key or ""}',
}
headers = {'Content-Type': 'application/json'}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
return headers
def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:

View file

@ -1,10 +1,11 @@
from __future__ import annotations
import ipaddress
from urllib.parse import urlparse
import validators
from open_webui.retrieval.web.utils import resolve_hostname
from open_webui.utils.misc import is_host_allowed
from open_webui.utils.misc import get_allow_block_lists, is_host_allowed
from pydantic import BaseModel
@ -12,6 +13,16 @@ def get_filtered_results(results, filter_list):
if not filter_list:
return results
allow_list, block_list = get_allow_block_lists(filter_list)
resolve_ips = False
for entry in allow_list + block_list:
try:
ipaddress.ip_address(entry)
except ValueError:
continue
resolve_ips = True
break
filtered_results = []
for result in results:
@ -25,12 +36,13 @@ def get_filtered_results(results, filter_list):
hostnames = [domain]
try:
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
hostnames.extend(ipv4_addresses)
hostnames.extend(ipv6_addresses)
except Exception:
pass
if resolve_ips:
try:
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
hostnames.extend(ipv4_addresses)
hostnames.extend(ipv6_addresses)
except Exception:
pass
if is_host_allowed(hostnames, filter_list):
filtered_results.append(result)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.session_pool import get_session
log = logging.getLogger(__name__)
async def search_openserp(
base_url: str,
query: str,
count: int,
filter_list: list[str | None] | None = None,
) -> list[SearchResult]:
"""Query an OpenSERP instance and return normalised results.
OpenSERP aggregates results from 6 engines (google, bing, yandex,
baidu, duckduckgo, ecosia) at once via ``/mega/search``.
No API key is required -- only a reachable OpenSERP base URL.
"""
url = f'{base_url.rstrip("/")}/mega/search'
params = {'text': query, 'limit': count}
log.debug('searching OpenSERP at %s', url)
session = await get_session()
async with session.get(url, params=params) as response:
response.raise_for_status()
payload = await response.json()
results = payload.get('results', [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=item.get('url', ''),
title=item.get('title'),
snippet=item.get('snippet'),
)
for item in results[:count]
]

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import logging
import ssl
from functools import lru_cache
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, SEARXNG_CLIENT_CERT_FILE, SEARXNG_CLIENT_KEY_FILE
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.session_pool import get_session
@ -17,6 +20,19 @@ _SEARXNG_HEADERS = {
}
@lru_cache
def _get_ssl_context() -> bool | ssl.SSLContext:
if not SEARXNG_CLIENT_CERT_FILE:
return AIOHTTP_CLIENT_SESSION_SSL
ssl_context = ssl.create_default_context()
ssl_context.load_cert_chain(
certfile=SEARXNG_CLIENT_CERT_FILE,
keyfile=SEARXNG_CLIENT_KEY_FILE or None,
)
return ssl_context
async def search_searxng(
query_url: str,
query: str,
@ -48,7 +64,12 @@ async def search_searxng(
log.debug('searching %s', query_url)
session = await get_session()
async with session.get(query_url, headers=_SEARXNG_HEADERS, params=params) as response:
async with session.get(
query_url,
headers=_SEARXNG_HEADERS,
params=params,
ssl=_get_ssl_context(),
) as response:
response.raise_for_status()
payload = await response.json()

View file

@ -3,9 +3,10 @@ import ipaddress
import logging
import socket
import ssl
import time
import urllib.parse
import urllib.request
from datetime import datetime, time, timedelta
from datetime import datetime, timedelta
from typing import (
Any,
AsyncIterator,
@ -74,6 +75,34 @@ def resolve_hostname(hostname):
return ipv4_addresses, ipv6_addresses
def _is_global_addr(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
if not addr.is_global:
return False
if not isinstance(addr, ipaddress.IPv6Address):
return True
embedded = []
if addr.ipv4_mapped:
embedded.append(addr.ipv4_mapped)
if addr.sixtofour:
embedded.append(addr.sixtofour)
if addr.teredo:
embedded.extend(addr.teredo)
b = addr.packed
if b[:12] == b"\x00" * 12:
embedded.append(ipaddress.IPv4Address(b[12:]))
elif b[:12] == b"\x00\x64\xff\x9b" + b"\x00" * 8:
embedded.append(ipaddress.IPv4Address(b[12:]))
elif b[:6] == b"\x00\x64\xff\x9b\x00\x01":
if b[8] != 0:
return False
embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10]))))
return all(ip.is_global for ip in embedded)
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if isinstance(validators.url(url), validators.ValidationError):
@ -110,8 +139,7 @@ def validate_url(url: Union[str, Sequence[str]]):
# Check if any of the resolved addresses are private
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter
for ip in ipv4_addresses + ipv6_addresses:
addr = ipaddress.ip_address(ip)
if not addr.is_global:
if not _is_global_addr(ip):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return True
elif isinstance(url, Sequence):
@ -146,7 +174,7 @@ def _ssrf_safe_new_conn(self):
raise OSError(f'getaddrinfo for {host!r} returned empty list')
if not ENABLE_LOCAL_WEB_FETCH:
for _, _, _, _, sa in infos:
if not ipaddress.ip_address(sa[0]).is_global:
if not _is_global_addr(sa[0]):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
err = None
for fam, typ, proto, _, sa in infos:
@ -207,7 +235,7 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
results = await super().resolve(host, port, family)
if not ENABLE_LOCAL_WEB_FETCH:
for entry in results:
if not ipaddress.ip_address(entry['host']).is_global:
if not _is_global_addr(entry['host']):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return results
@ -605,57 +633,63 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
def _intercept_navigation_sync(self, route, request=None):
req = request or route.request
if req.resource_type != 'document':
route.continue_()
return
try:
validate_url(req.url)
resp = route.fetch(max_redirects=0)
if 300 <= resp.status < 400:
for _ in range(20):
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
route.abort()
return
location = resp.headers.get('location')
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
validate_url(url)
resp = route.fetch(url=url, max_redirects=0)
if not 300 <= resp.status < 400:
break
else:
route.abort()
return
except Exception:
route.abort()
return
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
resp = route.fetch()
else:
try:
resp = route.fetch(max_redirects=0)
except TypeError:
route.abort()
return
if 300 <= resp.status < 400:
route.abort()
return
route.fulfill(response=resp)
async def _intercept_navigation(self, route, request=None):
req = request or route.request
if req.resource_type != 'document':
await route.continue_()
return
try:
await run_in_threadpool(validate_url, req.url)
resp = await route.fetch(max_redirects=0)
if 300 <= resp.status < 400:
for _ in range(20):
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
await route.abort()
return
location = resp.headers.get('location')
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
await run_in_threadpool(validate_url, url)
resp = await route.fetch(url=url, max_redirects=0)
if not 300 <= resp.status < 400:
break
else:
await route.abort()
return
except Exception:
await route.abort()
return
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
resp = await route.fetch()
else:
try:
resp = await route.fetch(max_redirects=0)
except TypeError:
await route.abort()
return
if 300 <= resp.status < 400:
await route.abort()
return
await route.fulfill(response=resp)
def lazy_load(self) -> Iterator[Document]:
@ -669,24 +703,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
else:
browser = p.chromium.launch(headless=self.headless, proxy=self.proxy)
for url in self.urls:
try:
self._safe_process_url_sync(url)
page = browser.new_page()
page.route('**/*', self._intercept_navigation_sync)
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
with browser:
for url in self.urls:
try:
self._safe_process_url_sync(url)
with browser.new_page(service_workers='block') as page:
page.route('**/*', self._intercept_navigation_sync)
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
text = self.evaluator.evaluate(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
browser.close()
text = self.evaluator.evaluate(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
async def alazy_load(self) -> AsyncIterator[Document]:
"""Safely load URLs asynchronously with support for remote browser."""
@ -699,24 +734,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
else:
browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy)
for url in self.urls:
try:
await self._safe_process_url(url)
page = await browser.new_page()
await page.route('**/*', self._intercept_navigation)
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
async with browser:
for url in self.urls:
try:
await self._safe_process_url(url)
async with await browser.new_page(service_workers='block') as page:
await page.route('**/*', self._intercept_navigation)
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
text = await self.evaluator.evaluate_async(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
await browser.close()
text = await self.evaluator.evaluate_async(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
class SafeWebBaseLoader(WebBaseLoader):
@ -728,6 +764,8 @@ class SafeWebBaseLoader(WebBaseLoader):
trust_env (bool, optional): set to True if using proxy to make web requests, for example
using http(s)_proxy environment variables. Defaults to False.
"""
# lxml parses scraped pages far faster than the html.parser default
kwargs.setdefault('default_parser', 'lxml')
super().__init__(*args, **kwargs)
self.trust_env = trust_env
@ -797,11 +835,6 @@ class SafeWebBaseLoader(WebBaseLoader):
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]:
"""Async fetch all urls, then return soups for all results."""
results = await self.fetch_all(urls)
return self._unpack_fetch_results(results, urls, parser=parser)
def lazy_load(self) -> Iterator[Document]:
"""Lazy load text from the url(s) in web_path with error handling."""
for path in self.web_paths:
@ -817,19 +850,20 @@ class SafeWebBaseLoader(WebBaseLoader):
# Log the error and continue with the next URL
log.exception(f'Error loading {path}: {e}')
def _document_from_html(self, html: str, url: str) -> Document:
"""Build one Document."""
soup = self._unpack_fetch_results([html], [url])[0]
return Document(
page_content=soup.get_text(**self.bs_get_text_kwargs),
metadata=extract_metadata(soup, url),
)
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path."""
results = await self.ascrape_all(self.web_paths)
for path, soup in zip(self.web_paths, results):
text = soup.get_text(**self.bs_get_text_kwargs)
metadata = {'source': path}
if title := soup.find('title'):
metadata['title'] = title.get_text()
if description := soup.find('meta', attrs={'name': 'description'}):
metadata['description'] = description.get('content', 'No description found.')
if html := soup.find('html'):
metadata['language'] = html.get('lang', 'No language found.')
yield Document(page_content=text, metadata=metadata)
results = await self.fetch_all(self.web_paths)
for path, html in zip(self.web_paths, results):
# parsing a large page costs hundreds of ms, keep it off the event loop
yield await asyncio.to_thread(self._document_from_html, html, path)
async def aload(self) -> list[Document]:
"""Load data into Document objects."""

View file

@ -1091,19 +1091,17 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'),
)
results = []
try:
tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
for coro in asyncio.as_completed(tasks):
try:
results.append(await coro)
except HTTPException:
raise
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Error transcribing chunk: {transcribe_exc}',
)
# gather keeps results in chunk order, unlike as_completed
results = await asyncio.gather(*tasks)
except HTTPException:
raise
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Error transcribing chunk: {transcribe_exc}',
)
finally:
# Clean up only the temporary chunks, never the original file
for chunk_path in chunk_paths:

View file

@ -26,6 +26,8 @@ from open_webui.env import (
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
WEBUI_AUTH,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@ -85,6 +87,17 @@ 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)
# Best-effort throttle only: there is no caller identity before the provider answers,
# and deployments may derive request.client from proxy headers.
token_exchange_rate_limiter = (
RateLimiter(
redis_client=get_redis_client(),
limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
)
if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None
else None
)
ADMIN_CONFIG_KEYS = {
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
@ -105,6 +118,7 @@ ADMIN_CONFIG_KEYS = {
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
'ENABLE_AUTOMATIONS': 'automations.enable',
'ENABLE_CHANNELS': 'channels.enable',
'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
'ENABLE_CALENDAR': 'calendar.enable',
'ENABLE_MEMORIES': 'memories.enable',
'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
@ -1185,6 +1199,7 @@ class AdminConfig(BaseModel):
AUTOMATION_MIN_INTERVAL: int | str | None = None
ENABLE_AUTOMATIONS: bool
ENABLE_CHANNELS: bool
CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
ENABLE_CALENDAR: bool
ENABLE_MEMORIES: bool
ENABLE_MEMORY_SYSTEM_CONTEXT: bool
@ -1208,6 +1223,9 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
updates.pop('ui.default_user_role', None)
if form_data.CHANNEL_MODEL_RESPONSE_MODE not in ['thread', 'channel']:
updates.pop('channels.model_response_mode', None)
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
# Check if the input string matches the pattern
@ -1292,6 +1310,7 @@ class OAuthConfigForm(BaseModel):
"""All OAuth/OIDC settings exposed to the admin panel."""
# General OAuth
ENABLE_OAUTH: bool | None = None
ENABLE_OAUTH_SIGNUP: bool | None = None
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
OAUTH_AUTO_REDIRECT: bool | None = None
@ -1347,6 +1366,7 @@ OAUTH_COMMA_LIST_FIELDS = {
OAUTH_CONFIG_KEYS = {
'ENABLE_OAUTH': 'oauth.enable',
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
@ -1552,6 +1572,14 @@ async def token_exchange(
detail='Token exchange is disabled',
)
if token_exchange_rate_limiter and token_exchange_rate_limiter.is_limited(
request.client.host if request.client else 'unknown'
):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
)
provider = provider.lower()
# Check if provider is configured

View file

@ -16,6 +16,7 @@ from open_webui.models.automations import (
Automations,
)
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.automations import (
@ -56,16 +57,11 @@ async def check_automations_permission(request, user):
def check_automation_access(automation, user):
if not automation:
if not automation or user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin' and user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False):
@ -97,6 +93,17 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
)
async def check_automation_folder_access(folder_id: Optional[str], user, db: AsyncSession):
if folder_id is None:
return
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
"""Full enrichment for single-item views (includes next_runs computation)."""
last_run = await AutomationRuns.get_latest(automation.id, db=db)
@ -117,6 +124,7 @@ async def get_automation_items(
request: Request,
query: Optional[str] = None,
status: Optional[str] = None,
folder_id: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@ -130,6 +138,7 @@ async def get_automation_items(
user_id=user.id,
query=query,
status=status,
folder_id=folder_id,
skip=skip,
limit=limit,
db=db,
@ -164,6 +173,7 @@ async def create_new_automation(
db: AsyncSession = Depends(get_async_session),
):
await check_automations_permission(request, user)
await check_automation_folder_access(form_data.folder_id, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
@ -182,7 +192,7 @@ async def create_new_automation(
EVENTS.AUTOMATION_CREATED,
actor=user,
subject_id=automation.id,
data={'name': automation.name, 'is_active': automation.is_active},
data={'name': automation.name, 'is_active': automation.is_active, 'folder_id': automation.folder_id},
)
return response
@ -221,6 +231,7 @@ async def update_automation_by_id(
await check_automations_permission(request, user)
automation = await Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
await check_automation_folder_access(form_data.folder_id, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
@ -240,7 +251,7 @@ async def update_automation_by_id(
EVENTS.AUTOMATION_UPDATED,
actor=user,
subject_id=updated.id,
data={'name': updated.name, 'is_active': updated.is_active},
data={'name': updated.name, 'is_active': updated.is_active, 'folder_id': updated.folder_id},
)
return response

View file

@ -987,13 +987,18 @@ async def model_response_handler(request, channel, message, user, db=None):
db=db,
)
)[::-1]
response_parent_id = (
message.parent_id
if message.parent_id
else (message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None)
)
response_message, channel = await new_message_handler(
request,
channel.id,
MessageForm(
**{
'parent_id': (message.parent_id if message.parent_id else message.id),
'parent_id': response_parent_id,
'content': f'',
'data': {},
'meta': {

View file

@ -6,8 +6,9 @@ import logging
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
@ -37,7 +38,7 @@ from open_webui.socket.main import get_event_emitter
from open_webui.tasks import has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
from open_webui.utils.misc import get_message_list
@ -52,6 +53,7 @@ router = APIRouter()
SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
CHAT_CONFIG_KEYS = {
'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model',
'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',
@ -60,6 +62,47 @@ CHAT_CONFIG_KEYS = {
}
async def get_optional_verified_user(
request: Request,
response: Response,
background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security),
):
try:
user = await get_current_user(request, response, background_tasks, auth_token)
except HTTPException:
return None
if user.role not in {'user', 'admin'}:
return None
return user
async def is_open_shared_chat(shared, db: AsyncSession) -> bool:
return await AccessGrants.has_anyone_access(
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
if user.role == 'pending':
return False
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
return True
if shared.user_id == user.id:
return True
return await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
@ -73,7 +116,26 @@ async def add_active_state_to_chat_list(
return chat_list
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
user_folders = await Folders.get_folders_by_user_id(user_id, db=db)
parent_by_id = {folder.id: folder.parent_id for folder in user_folders}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
class ChatConfigForm(BaseModel):
CONTEXT_COMPACTION_MODEL: str | None = ''
ENABLE_CONTEXT_COMPACTION: bool
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
@ -125,6 +187,8 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
async def get_chat_config_values() -> dict:
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
if config.get('CONTEXT_COMPACTION_MODEL') is None:
config['CONTEXT_COMPACTION_MODEL'] = ''
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:
@ -161,6 +225,8 @@ async def get_session_user_chat_list(
page: int | None = None,
include_pinned: bool | None = False,
include_folders: bool | None = False,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession = Depends(get_async_session),
):
try:
@ -172,6 +238,8 @@ async def get_session_user_chat_list(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
sort_by=sort_by,
sort_dir=sort_dir,
skip=skip,
limit=limit,
db=db,
@ -181,6 +249,8 @@ async def get_session_user_chat_list(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
@ -189,6 +259,17 @@ async def get_session_user_chat_list(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@router.post('/read')
async def mark_chats_read_by_user_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return {
'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db),
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
############################
# GetChatUsageStats
# EXPERIMENTAL: may be removed in future releases
@ -693,7 +774,7 @@ async def create_new_chat(
subject_id=chat.id,
data={'title': chat.title, 'folder_id': chat.folder_id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@ -747,6 +828,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
chat_config_updates(
{
**form_data.model_dump(),
'CONTEXT_COMPACTION_MODEL': form_data.CONTEXT_COMPACTION_MODEL or '',
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
@ -778,7 +860,17 @@ async def search_user_chats(
search_text = chat_search_content_text(text)
chat_list = []
for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
chat_list.append(ChatTitleIdResponse(**chat.model_dump(), snippet=chat_search_snippet(chat.chat, search_text)))
# Explicit fields: model_dump() would deep-copy the entire chat blob per row
chat_list.append(
ChatTitleIdResponse(
id=chat.id,
title=chat.title,
updated_at=chat.updated_at,
created_at=chat.created_at,
last_read_at=chat.last_read_at,
snippet=chat_search_snippet(chat.chat, search_text),
)
)
# Delete tag if no chat is found
words = text.strip().split(' ')
@ -807,7 +899,7 @@ async def get_chats_by_folder_id(
folder_ids.extend([folder.id for folder in children_folders])
return [
ChatResponse(**chat.model_dump())
ChatResponse.model_validate(chat, from_attributes=True)
for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db)
]
@ -817,6 +909,8 @@ async def get_chat_list_by_folder_id(
request: Request,
folder_id: str,
page: int | None = 1,
sort_by: str = 'unread_updated_at',
sort_dir: str = 'desc',
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -824,7 +918,15 @@ async def get_chat_list_by_folder_id(
limit = 10
skip = (page - 1) * limit
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
chats = await Chats.get_chats_by_folder_id_and_user_id(
folder_id,
user.id,
skip=skip,
limit=limit,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
except Exception as e:
@ -873,7 +975,7 @@ async def generate_chat_export_ndjson(user_id: str):
for chat in result.items:
try:
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n'
except Exception as e:
log.exception(f'Error serializing chat {chat.id}: {e}')
@ -898,7 +1000,10 @@ async def get_user_chats(user=Depends(get_verified_user)):
@router.get('/all/archived', response_model=list[ChatResponse])
async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)]
return [
ChatResponse.model_validate(chat, from_attributes=True)
for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)
]
############################
@ -925,7 +1030,7 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession =
async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_ADMIN_EXPORT:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)]
return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)]
############################
@ -1082,38 +1187,30 @@ async def get_shared_session_user_chat_list(
@router.get('/share/{share_id}', response_model=ChatResponse | None)
async def get_shared_chat_by_id(
share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session)
):
if user.role == 'pending':
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
shared = await SharedChats.get_by_id(share_id, db=db)
if shared:
if await is_open_shared_chat(shared, db=db) or (
user is not None and await can_read_shared_chat(user, shared, db=db)
):
chat = await Chats.get_chat_by_share_id(share_id, db=db)
if chat:
return ChatResponse.model_validate(chat, from_attributes=True)
chat = await Chats.get_chat_by_share_id(share_id, db=db)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN,
)
# Fallback: admins can also access any chat directly by chat ID
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = None
if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(share_id, db=db)
if chat:
return ChatResponse.model_validate(chat, from_attributes=True)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
# Look up the original chat_id to check access grants (admins bypass)
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
shared = await SharedChats.get_by_id(share_id, db=db)
if shared and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return ChatResponse(**chat.model_dump())
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
############################
@ -1236,7 +1333,7 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
chat = candidate
if chat:
data = ChatResponse(**chat.model_dump()).model_dump()
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
data['context_usage'] = await get_chat_context_usage(chat)
return data
@ -1265,9 +1362,18 @@ async def update_chat_by_id(
form_data.chat.get('history'),
)
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
touch = 'history' in form_data.chat or 'messages' in form_data.chat
chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch)
if form_data.variables is not None:
chat = await Chats.update_chat_variables_by_id(id, form_data.variables, db=db) or chat
chat = (
await Chats.update_chat_variables_by_id(
id,
form_data.variables,
db=db,
touch=False,
)
or chat
)
# Reconcile chat_message rows without inferring deletes from missing IDs.
# Message deletion has its own endpoint below.
@ -1282,7 +1388,7 @@ async def update_chat_by_id(
subject_id=id,
data={'title': chat.title},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -1356,7 +1462,7 @@ async def update_chat_message_by_id(
subject_id=message_id,
data={'chat_id': id, 'content_preview': form_data.content[:300]},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None)
@ -1395,7 +1501,7 @@ async def delete_chat_message_by_id(
subject_id=message_id,
data={'chat_id': id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
############################
@ -1466,64 +1572,48 @@ async def delete_chat_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
# Cancel any in-flight LLM tasks (streaming, title/tags generation)
# before deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
async def delete_internal_children(owner_id: str) -> None:
child_ids = await Chats.get_internal_chat_ids_by_parent_id(id, owner_id)
for child_id in child_ids:
await stop_item_tasks(request.app.state.redis, child_id)
await Chats.delete_chat_by_id_and_user_id(child_id, owner_id)
await stop_item_tasks(request.app.state.redis, id)
# Authorize before any side effect: cancelling a chat's in-flight tasks must
# not be reachable for a chat the caller may not delete.
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(chat.user_id)
result = await Chats.delete_chat_by_id(id, db=db)
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': chat.user_id},
)
return result
else:
if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(user.id)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Cancel any in-flight LLM tasks (streaming, title/tags generation) before
# deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
# Cascade to internal child chats spawned from this one.
for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id):
await stop_item_tasks(request.app.state.redis, child_id)
await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id)
if user.role == 'admin':
result = await Chats.delete_chat_by_id(id, db=db)
else:
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': user.id},
)
return result
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': chat.user_id},
)
return result
############################
@ -1667,7 +1757,7 @@ async def fork_chat_by_id(
subject_id=fork.id,
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
)
return ChatResponse(**fork.model_dump())
return ChatResponse.model_validate(fork, from_attributes=True)
@router.post('/{id}/clone', response_model=ChatResponse | None)
@ -1714,7 +1804,7 @@ async def clone_chat_by_id(
subject_id=chat.id,
data={'original_chat_id': id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -1753,7 +1843,7 @@ async def clone_shared_chat_by_id(
# Enforce access grants (owner and admins bypass)
shared = await SharedChats.get_by_id(id, db=db)
if shared and user.role != 'admin' and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
@ -1791,7 +1881,7 @@ async def clone_shared_chat_by_id(
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -1832,7 +1922,7 @@ async def archive_chat_by_id(
subject_id=id,
subject_type='chat',
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
@ -1866,7 +1956,7 @@ async def share_chat_by_id(
subject_id=id,
data={'share_id': chat.share_id, 'updated': True},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
# Create a new share
shared = await SharedChats.create(id, user.id, db=db)
@ -1884,7 +1974,7 @@ async def share_chat_by_id(
subject_id=id,
data={'share_id': shared.id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
# --- Delete Shared Chat ---
@ -1948,11 +2038,13 @@ async def update_shared_chat_access_by_id(
user.role,
form_data.access_grants,
'sharing.public_chats',
'sharing.open_chats',
db=db,
)
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
############################
@ -1997,6 +2089,28 @@ class ChatFolderIdForm(BaseModel):
folder_id: str | None = None
@router.post('/{id}/unread')
async def mark_chat_unread_by_id(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
folder_id = await Chats.get_chat_folder_id(id, user.id, db=db)
return {
'chat_id': id,
'last_read_at': chat.last_read_at,
'folder_id': folder_id,
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
@router.post('/{id}/folder', response_model=ChatResponse | None)
async def update_chat_folder_id_by_id(
request: Request,
@ -2027,7 +2141,7 @@ async def update_chat_folder_id_by_id(
subject_id=id,
data={'folder_id': form_data.folder_id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())

View file

@ -205,7 +205,7 @@ async def register_oauth_client(
log.debug(f'Failed to register OAuth client: {e}')
raise HTTPException(
status_code=400,
detail=f'Failed to register OAuth client',
detail=f'Failed to register OAuth client: {e}',
)
@ -614,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
custom_headers = get_custom_headers(form_data.headers, user)
custom_headers = await get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
await client.connect(form_data.url, headers=headers)
@ -659,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
custom_headers = get_custom_headers(form_data.headers, user)
custom_headers = await get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
url = get_tool_server_url(form_data.url, form_data.path)

View file

@ -108,6 +108,23 @@ def _cleanup_local_cache(file_path: str) -> None:
log.warning(f'Failed to clean up local cache for {file_path}: {e}')
def _matches_configured_mime_type(supported: list[str] | str, content_type: str) -> bool:
if isinstance(supported, str):
supported = supported.split(',')
supported = [item.strip() for item in (supported or []) if item.strip()]
if not supported:
return False
return bool(strict_match_mime_type(supported, content_type))
def _media_supported_for_extraction(
content_extraction_engine: str | None, supported: list[str] | str | None, content_type: str
) -> bool:
if supported is None:
return content_extraction_engine == 'external'
return bool(content_extraction_engine and _matches_configured_mime_type(supported, content_type))
async def process_uploaded_file(
request,
file,
@ -127,6 +144,10 @@ async def process_uploaded_file(
content_type = 'text/plain'
stt_supported = await Config.get('audio.stt.supported_content_types', [])
content_extraction_engine = await Config.get('rag.content_extraction_engine')
content_extraction_supported_media_mime_types = await Config.get(
'rag.content_extraction.supported_media_mime_types'
)
if content_type and strict_match_mime_type(stt_supported, content_type):
# Audio / STT-supported files → transcribe then index
@ -147,9 +168,10 @@ async def process_uploaded_file(
elif (
content_type
and content_type.startswith(('image/', 'video/'))
and await Config.get('rag.content_extraction_engine') != 'external'
and not _media_supported_for_extraction(
content_extraction_engine, content_extraction_supported_media_mime_types, content_type
)
):
# Media files without an external extraction engine
if content_type.startswith('video/'):
# Videos are stored as-is for downstream multimodal
# processing (Tools, vision models). Attempting text
@ -165,7 +187,8 @@ async def process_uploaded_file(
raise Exception(f'File type {content_type} is not supported for processing')
else:
# Documents, or any file when an external engine is configured
# Documents, or media files explicitly enabled for the
# configured content extraction engine.
if not content_type:
log.info(f'File type {file.content_type} is not provided, but trying to process anyway')
await process_file(

View file

@ -12,6 +12,7 @@ from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.folders import (
@ -22,6 +23,7 @@ from open_webui.models.folders import (
FolderUpdateForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
@ -30,6 +32,7 @@ from open_webui.utils.access_control import (
)
from open_webui.utils.access_control.files import get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.tasks import has_active_tasks
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -42,6 +45,24 @@ router = APIRouter()
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
folders = await Folders.get_folders_by_user_id(user_id, db=db)
parent_by_id = {folder.id: folder.parent_id for folder in folders}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
async def check_folders_permission(request: Request, user, db=None):
"""Verify the folders feature is enabled and the user has permission."""
config = await Config.get_many('folders.enable', 'user.permissions')
@ -92,9 +113,14 @@ async def get_folders(
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
)
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
folder_list.append(folder)
return folder_list
unread_counts = await get_folder_unread_counts(user.id, db=db)
return [
FolderNameIdResponse(**folder.model_dump(), unread_count=unread_counts.get(folder.id, 0))
for folder in folder_list
]
############################
@ -484,7 +510,7 @@ async def get_shared_folder_chats(
request: Request,
id: str,
page: int | None = Query(None, ge=1),
sort_by: str = Query('updated_at'),
sort_by: str = Query('unread_updated_at'),
sort_dir: str = Query('desc'),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@ -517,6 +543,7 @@ async def get_shared_folder_chats(
limit=limit if page is not None else 60,
sort_by=sort_by,
sort_dir=sort_dir,
unread_for_user_id=user.id,
db=db,
)
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
@ -529,6 +556,11 @@ async def get_shared_folder_chats(
u = await Users.get_user_by_id(uid, db=db)
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
chat['active'] = False
if chat['user_id'] != user.id:
chat['last_read_at'] = chat['updated_at']
if await has_active_tasks(request.app.state.redis, chat['id']):
chat['active'] = await ChatMessages.has_unfinished_assistant_by_chat_id(chat['id'], db=db)
response = {
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
@ -539,6 +571,44 @@ async def get_shared_folder_chats(
return response
@router.post('/{id}/read')
async def mark_folder_chats_read_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id(id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
is_owner = user.id == folder.user_id
is_admin = user.role == 'admin'
if not (is_owner or is_admin or await _has_folder_access(user.id, folder, 'read', db)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder_ids = (
await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder.user_id, db=db)
if is_owner or is_admin
else [id]
)
updated_count = await Chats.mark_chats_read_by_folder_ids(user.id, folder_ids, db=db)
return {
'folder_id': id,
'folder_ids': folder_ids,
'updated_count': updated_count,
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
############################
# Delete Folder By Id
############################
@ -556,26 +626,18 @@ async def delete_folder_by_id(
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if not folder:
# Check if it's a shared subfolder with write access
# Deletion cascades into the owner's data, so only the owner or an admin may delete
folder = await Folders.get_folder_by_id(id, db=db)
if folder and folder.parent_id:
if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
elif folder and not folder.parent_id:
# Root shared folders can only be deleted by owner/admin
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder_owner_id = folder.user_id
@ -607,6 +669,8 @@ async def delete_folder_by_id(
# Clean up access grants for this folder
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
await Automations.clear_folder_ids(folder_owner_id, folder_ids, db=db)
await publish_event(
request,
EVENTS.FOLDER_DELETED,

View file

@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
from open_webui.events import EVENTS, publish_event
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
from open_webui.internal.db import get_async_session
from open_webui.models.functions import (
FunctionForm,
@ -293,6 +293,22 @@ async def toggle_function_by_id(
):
function = await Functions.get_function_by_id(id, db=db)
if function:
lifecycle_event = build_event(
request,
EVENTS.FUNCTION_DISABLE_STARTED if function.is_active else EVENTS.FUNCTION_ENABLE_STARTED,
actor=user,
subject_id=function.id,
subject_type='function',
data={'type': function.type, 'name': function.name},
)
await dispatch_event_functions(
request.app,
lifecycle_event,
request=request,
extra_function_ids=[function.id] if not function.is_active else None,
)
schedule_webhook_dispatch(request.app, lifecycle_event)
function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db)
if function:

View file

@ -12,7 +12,7 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, RAG_EMBEDDING_CONTENT_PREFIX
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
@ -21,6 +21,7 @@ from open_webui.models.config import Config
from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files
from open_webui.models.groups import Groups
from open_webui.models.knowledge import (
KNOWLEDGE_SORTABLE_FIELDS,
KnowledgeDirectoryForm,
KnowledgeDirectoryModel,
KnowledgeFileListResponse,
@ -73,7 +74,7 @@ async def embed_knowledge_base_metadata(
"""Generate and store embedding for knowledge base."""
try:
content = f'{name}\n\n{description}' if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
embedding = await request.app.state.EMBEDDING_FUNCTION(content, prefix=RAG_EMBEDDING_CONTENT_PREFIX)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
@ -181,6 +182,8 @@ async def search_knowledge_bases(
view_option: str | None = None,
source: str | None = None,
page: int | None = 1,
order_by: str | None = None,
direction: str | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -195,6 +198,10 @@ async def search_knowledge_bases(
filter['view_option'] = view_option
if source in {'local', 'external'}:
filter['source'] = source
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
filter['order_by'] = order_by
if direction in {'asc', 'desc'}:
filter['direction'] = direction
groups = await Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
@ -1936,6 +1943,10 @@ async def sync_knowledge_cleanup(
if not file:
continue
# Only clean up files that belong to this knowledge base.
if not await Knowledges.has_file(id, file_id, db=db):
continue
await Knowledges.remove_file_from_knowledge_by_id(id, file_id, db=db)
try:
@ -1960,6 +1971,10 @@ async def sync_knowledge_cleanup(
# ── Remove orphaned directories (children before parents) ──
for dir_id in reversed(form_data.dir_ids):
# Only delete directories that belong to this knowledge base.
directory = await Knowledges.get_directory_by_id(dir_id, db=db)
if not directory or directory.knowledge_id != id:
continue
await Knowledges.delete_directory(dir_id, move_files_to_parent=False, db=db)
return {'status': True}

View file

@ -5,13 +5,13 @@ import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import RAG_EMBEDDING_CONTENT_PREFIX, RAG_EMBEDDING_QUERY_PREFIX
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.memory import (
@ -148,7 +148,9 @@ async def add_memory(
meta={'created_by': 'manual'},
)
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
@ -208,6 +210,7 @@ async def update_memories(
if result.get('status') in {'created', 'updated'}:
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
upsert_items.append(
@ -284,7 +287,7 @@ async def query_memory(
if not memories:
raise HTTPException(status_code=404, detail='No memories found for user')
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, RAG_EMBEDDING_QUERY_PREFIX, user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user)
results = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=f'user-memory-{user.id}',
@ -407,7 +410,9 @@ async def reset_memory_from_vector_db(
# Generate vectors in parallel
vectors = await asyncio.gather(
*[
request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
for memory in memories
]
)
@ -503,7 +508,9 @@ async def update_memory_by_id(
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
if form_data.content is not None or form_data.path is not None:
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',

View file

@ -50,7 +50,7 @@ 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)
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
@ -189,16 +189,16 @@ async def get_models(
data = add_chat_variables_schema(model.model_dump())
if data.get('meta'):
data['meta'].pop('profile_image_url', None)
items.append(
ModelAccessResponse(
**data,
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or model.id in writable_model_ids
),
)
write_access = (
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or model.id in writable_model_ids
)
# Strip params (system prompt and other curated config) for read-only
# callers, mirroring the per-id endpoint.
if not write_access:
data['params'] = {}
items.append(ModelAccessResponse(**data, write_access=write_access))
return ModelAccessListResponse(
items=items,

View file

@ -24,7 +24,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
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,
@ -41,13 +40,15 @@ from open_webui.models.users import UserModel
from open_webui.utils.access_control import check_model_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import calculate_sha256
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
log = logging.getLogger(__name__)
@ -56,6 +57,7 @@ log = logging.getLogger(__name__)
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
def _clean_proxy_headers(raw_headers) -> dict:
@ -83,9 +85,9 @@ async def send_get_request(
url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as r:
return await r.json()
return await r.json(loads=JSONCodec.loads)
except Exception as exc:
log.error(f'Connection error: {exc}')
return None
@ -99,6 +101,8 @@ async def send_request(
key: str | None = None,
user: UserModel = None,
stream: bool = False,
# passthrough must stay False for /api/chat: middleware parses it per line
passthrough: bool = False,
content_type: str | None = None,
metadata: dict | None = None,
api_config: dict | None = None,
@ -121,7 +125,7 @@ async def send_request(
# Custom per-connection headers last so admin-set headers take precedence.
if api_config and api_config.get('headers'):
headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request))
headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request))
r = await session.request(
method,
@ -129,12 +133,12 @@ async def send_request(
data=payload,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=stream),
)
if not r.ok:
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
await publish_model_provider_request_failed(
request,
actor=user,
@ -170,13 +174,13 @@ async def send_request(
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=passthrough),
status_code=r.status,
headers=response_headers,
)
else:
try:
return await r.json()
return await r.json(loads=JSONCodec.loads)
except Exception:
return None
@ -263,16 +267,16 @@ async def verify_connection(
f'{form_data.url}/api/version',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as r:
if r.status != 200:
detail = f'HTTP Error: {r.status}'
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
detail = f'External Error: {res["error"]}'
raise Exception(detail)
return await r.json()
return await r.json(loads=JSONCodec.loads)
except aiohttp.ClientError as exc:
log.exception(f'Client error: {exc}')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
@ -535,6 +539,7 @@ async def get_ollama_loaded_models(
@router.get('/api/version/{url_idx}')
async def get_ollama_versions(
request: Request,
user=Depends(get_verified_user),
url_idx: int | None = None,
):
"""Return the lowest Ollama version across all configured backends."""
@ -656,6 +661,7 @@ async def pull_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@ -695,6 +701,7 @@ async def push_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@ -727,6 +734,7 @@ async def create_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@ -895,8 +903,7 @@ async def embed(
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/embed',
@ -947,8 +954,7 @@ async def embeddings(
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/embeddings',
@ -1003,8 +1009,7 @@ async def generate_completion(
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/generate',
@ -1012,6 +1017,7 @@ async def generate_completion(
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=True,
passthrough=True,
)
@ -1063,6 +1069,10 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
await validate_ollama_backend_idx(request, model, url_idx, user)
if url_idx is None:
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models.cache.clear()
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(
status_code=400,
@ -1107,7 +1117,7 @@ async def generate_chat_completion(
raise HTTPException(status_code=400, detail=str(exc))
if isinstance(form_data, BaseModel):
payload = {**form_data.model_dump(exclude_none=True)}
payload = form_data.model_dump(exclude_none=True)
payload.pop('metadata', None)
@ -1135,8 +1145,7 @@ async def generate_chat_completion(
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/api/chat',
@ -1212,8 +1221,7 @@ async def generate_openai_completion(
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
@ -1232,8 +1240,7 @@ async def generate_openai_completion(
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/completions',
@ -1241,6 +1248,7 @@ async def generate_openai_completion(
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
metadata=metadata,
api_config=api_config,
request=request,
@ -1267,7 +1275,7 @@ async def generate_openai_embeddings(
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True)}
payload = form_data.model_dump(exclude_none=True)
payload.pop('metadata', None)
model_id = form_data.model
@ -1283,8 +1291,7 @@ async def generate_openai_embeddings(
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/embeddings',
@ -1318,8 +1325,7 @@ async def generate_openai_chat_completion(
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
@ -1342,8 +1348,7 @@ async def generate_openai_chat_completion(
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/chat/completions',
@ -1351,6 +1356,7 @@ async def generate_openai_chat_completion(
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
metadata=metadata,
api_config=api_config,
request=request,
@ -1394,8 +1400,7 @@ async def generate_anthropic_messages(
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/messages',
@ -1403,6 +1408,7 @@ async def generate_anthropic_messages(
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
@ -1452,8 +1458,7 @@ async def generate_responses(
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/responses',
@ -1461,6 +1466,7 @@ async def generate_responses(
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
@ -1661,7 +1667,7 @@ async def upload_model(
data=blob_chunks(),
headers={'Content-Length': str(total_size)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
) as resp:
if not resp.ok:
raise Exception('Ollama: Could not create blob, Please try again.')
@ -1684,7 +1690,7 @@ async def upload_model(
headers={'Content-Type': 'application/json'},
data=json.dumps(create_payload),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
) as create_resp:
if create_resp.ok:
log.info('API SUCCESS!')

View file

@ -26,7 +26,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_FORWARD_USER_INFO_HEADERS,
@ -44,6 +43,8 @@ from open_webui.utils.access_control import check_model_access, has_connection_a
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
stream_chunks_handler,
@ -54,6 +55,7 @@ from open_webui.utils.payload import (
)
from open_webui.utils.session_pool import (
cleanup_response,
get_client_timeout,
get_session,
stream_wrapper,
)
@ -76,6 +78,8 @@ log = logging.getLogger(__name__)
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
_UNSUPPORTED_OPENAI_MODEL_KEYWORDS = ('babbage', 'dall-e', 'davinci', 'embedding', 'tts', 'whisper')
def _clean_proxy_headers(raw_headers) -> dict:
@ -90,9 +94,8 @@ async def send_get_request(
user: UserModel = None,
config=None,
):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with aiohttp.ClientSession(timeout=_MODEL_LIST_TIMEOUT, trust_env=True) as session:
if request and config:
headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user)
else:
@ -110,7 +113,7 @@ async def send_get_request(
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
return await response.json(loads=JSONCodec.loads)
except Exception as e:
# Handle connection error here
log.error(f'Connection error: {e}')
@ -210,7 +213,7 @@ async def get_headers_and_cookies(
headers['Authorization'] = f'Bearer {token}'
if config.get('headers') and isinstance(config.get('headers'), dict):
custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request)
custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request)
headers.update(custom_headers)
return headers, cookies
@ -328,8 +331,7 @@ async def get_anthropic_token_count_target(request: Request, form_data: dict, us
url, key, api_config = await get_openai_connection(model['urlIdx'])
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
return requested_model, payload, url, key, headers, cookies
@ -352,11 +354,11 @@ async def count_anthropic_tokens(request: Request, form_data: dict, user: UserMo
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
)
try:
response_data = await response.json()
response_data = await response.json(loads=JSONCodec.loads)
except Exception:
response_data = await response.text()
@ -508,7 +510,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
detail = None
if r is not None:
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
detail = f'External: {res["error"]}'
except Exception:
@ -659,19 +661,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
return None
def is_supported_openai_models(model_id):
if any(
name in model_id
for name in [
'babbage',
'dall-e',
'davinci',
'embedding',
'tts',
'whisper',
]
):
return False
return True
return not any(name in model_id for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
def get_merged_models(model_lists):
log.debug(f'merge_models_lists {model_lists}')
@ -679,17 +669,18 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
for idx, model_list in enumerate(model_lists):
if model_list is not None and 'error' not in model_list:
base_url = api_base_urls[idx]
hostname = urlparse(base_url).hostname if base_url else None
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
for model in model_list:
model_id = model.get('id') or model.get('name')
base_url = api_base_urls[idx]
hostname = urlparse(base_url).hostname if base_url else None
if hostname == 'api.openai.com' and not is_supported_openai_models(model_id):
# Skip unwanted OpenAI models
continue
if model_id and model_id not in models:
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
provider = model.get('provider', '')
merged = {
**model,
@ -738,7 +729,7 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
r = None
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as session:
try:
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
@ -762,30 +753,20 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
if r.status != 200:
error_detail = f'HTTP Error: {r.status}'
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
error_detail = f'External Error: {res["error"]}'
except Exception:
pass
raise Exception(error_detail)
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
if 'api.openai.com' in url:
response_data['data'] = [
model
for model in response_data.get('data', [])
if not any(
name in model['id']
for name in [
'babbage',
'dall-e',
'davinci',
'embedding',
'tts',
'whisper',
]
)
if not any(name in model['id'] for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
]
models = response_data
@ -824,7 +805,7 @@ async def verify_connection(
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as session:
try:
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
@ -852,7 +833,7 @@ async def verify_connection(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@ -878,7 +859,7 @@ async def verify_connection(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@ -1268,8 +1249,7 @@ async def generate_chat_completion(
url, key, api_config = await get_openai_connection(idx)
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
# Add user info to the payload if the model is a pipeline
if 'pipeline' in model and model.get('pipeline'):
@ -1345,6 +1325,10 @@ async def generate_chat_completion(
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
)
is_streaming_request = bool(payload.get('stream', False))
if not is_streaming_request:
payload.pop('stream_options', None)
payload = json.dumps(payload)
r = None
@ -1361,7 +1345,7 @@ async def generate_chat_completion(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
@ -1413,7 +1397,7 @@ async def generate_chat_completion(
)
else:
try:
response = await r.json()
response = await r.json(loads=JSONCodec.loads)
except Exception as e:
log.error(e)
response = await r.text()
@ -1512,20 +1496,20 @@ async def embeddings(request: Request, form_data: dict, user):
data=body,
headers=headers,
cookies=cookies,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@ -1588,6 +1572,7 @@ async def responses(
Routes to the correct upstream backend based on the model field.
"""
payload = form_data.model_dump(exclude_none=True)
is_streaming_request = bool(payload.get('stream', False))
idx = 0
model_id = form_data.model
@ -1638,20 +1623,20 @@ async def responses(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@ -1708,6 +1693,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
payload = json.loads(body)
except (json.JSONDecodeError, ValueError):
payload = None
is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False
idx = 0
model_id = payload.get('model') if isinstance(payload, dict) else None
@ -1759,20 +1745,20 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()

View file

@ -101,6 +101,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud
from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.searchapi import search_searchapi
from open_webui.retrieval.web.openserp import search_openserp
from open_webui.retrieval.web.searxng import search_searxng
from open_webui.retrieval.web.serpapi import search_serpapi
from open_webui.retrieval.web.serper import search_serper
@ -264,6 +265,7 @@ RETRIEVAL_CONFIG_KEYS = {
'CHUNK_MIN_SIZE_TARGET': 'rag.chunk_min_size_target',
'CHUNK_OVERLAP': 'rag.chunk_overlap',
'CHUNK_SIZE': 'rag.chunk_size',
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': 'rag.content_extraction.supported_media_mime_types',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
@ -365,6 +367,7 @@ RETRIEVAL_CONFIG_KEYS = {
'SEARCHAPI_ENGINE': 'web.search.searchapi_engine',
'SEARXNG_LANGUAGE': 'web.search.searxng_language',
'SEARXNG_QUERY_URL': 'web.search.searxng_query_url',
'OPENSERP_BASE_URL': 'web.search.openserp_base_url',
'SERPAPI_API_KEY': 'web.search.serpapi_api_key',
'SERPAPI_ENGINE': 'web.search.serpapi_engine',
'SERPER_API_KEY': 'web.search.serper_api_key',
@ -628,6 +631,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
# Content extraction settings
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
@ -703,6 +707,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
'YACY_QUERY_URL': config.YACY_QUERY_URL,
'YACY_USERNAME': config.YACY_USERNAME,
'YACY_PASSWORD': config.YACY_PASSWORD,
@ -781,6 +786,7 @@ class WebConfig(BaseModel):
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: str | None = None
SEARXNG_QUERY_URL: str | None = None
SEARXNG_LANGUAGE: str | None = None
OPENSERP_BASE_URL: str | None = None
YACY_QUERY_URL: str | None = None
YACY_USERNAME: str | None = None
YACY_PASSWORD: str | None = None
@ -857,6 +863,7 @@ class ConfigForm(BaseModel):
# Content extraction settings
CONTENT_EXTRACTION_ENGINE: str | None = None
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES: list[str] | None = None
PDF_EXTRACT_IMAGES: bool | None = None
PDF_LOADER_MODE: str | None = None
@ -969,6 +976,11 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
if form_data.CONTENT_EXTRACTION_ENGINE is not None
else config.CONTENT_EXTRACTION_ENGINE
)
config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
if form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES is not None
else config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
)
config.PDF_EXTRACT_IMAGES = (
form_data.PDF_EXTRACT_IMAGES if form_data.PDF_EXTRACT_IMAGES is not None else config.PDF_EXTRACT_IMAGES
)
@ -1249,6 +1261,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE
config.OPENSERP_BASE_URL = form_data.web.OPENSERP_BASE_URL
config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
config.YACY_USERNAME = form_data.web.YACY_USERNAME
config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
@ -1328,6 +1341,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
# Content extraction settings
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
@ -1400,6 +1414,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
'YACY_QUERY_URL': config.YACY_QUERY_URL,
'YACY_USERNAME': config.YACY_USERNAME,
'YACY_PASSWORD': config.YACY_PASSWORD,
@ -1564,12 +1579,21 @@ def get_transformers_tokenizer(request: Request, config: RetrievalConfig):
if not os.path.exists(tokenizer_model) and '/' not in tokenizer_model:
tokenizer_model = f'sentence-transformers/{tokenizer_model}'
return AutoTokenizer.from_pretrained(
cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE')
local_files_only = not RAG_EMBEDDING_MODEL_AUTO_UPDATE
tokenizer_key = (tokenizer_model, cache_dir, local_files_only)
cached_tokenizer = getattr(request.app.state, 'transformers_tokenizer', None)
if cached_tokenizer and cached_tokenizer[0] == tokenizer_key:
return cached_tokenizer[1]
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_model,
cache_dir=os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE'),
cache_dir=cache_dir,
trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
local_files_only=not RAG_EMBEDDING_MODEL_AUTO_UPDATE,
local_files_only=local_files_only,
)
request.app.state.transformers_tokenizer = (tokenizer_key, tokenizer)
return tokenizer
tokenizer = getattr(getattr(request.app.state, 'ef', None), 'tokenizer', None)
if tokenizer is not None:
@ -1937,7 +1961,7 @@ async def process_file(
]
text_content = ' '.join([doc.page_content for doc in docs])
log.debug(f'text_content: {text_content}')
log.debug('text_content: %s', text_content)
await Files.update_file_data_by_id(
file.id,
{'content': text_content},
@ -2079,7 +2103,7 @@ async def process_text(
)
]
text_content = form_data.content
log.debug(f'text_content: {text_content}')
log.debug('text_content: %s', text_content)
config = await get_retrieval_config()
result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, config, user=user)
@ -2116,7 +2140,7 @@ async def process_web(
config = await get_retrieval_config()
try:
content, docs = await get_content_from_url(request, form_data.url)
log.debug(f'text_content: {content}')
log.debug('text_content: %s', content)
if process:
collection_name = form_data.collection_name
@ -2212,6 +2236,16 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
)
else:
raise Exception('No SEARXNG_QUERY_URL found in environment variables')
elif engine == 'openserp':
if config.OPENSERP_BASE_URL:
return await search_openserp(
config.OPENSERP_BASE_URL,
query,
config.WEB_SEARCH_RESULT_COUNT,
config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception('No OPENSERP_BASE_URL found in environment variables')
elif engine == 'yacy':
if config.YACY_QUERY_URL:
return await asyncio.to_thread(
@ -2607,20 +2641,22 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
if hasattr(result, 'snippet') and result.snippet is not None
]
else:
loader_config = await get_loader_config()
loader = get_web_loader(
urls,
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(),
verify_ssl=loader_config.get('web_loader_ssl_verification'),
requests_per_second=loader_config.get('web_loader_concurrent_requests'),
trust_env=loader_config.get('web_search_trust_env'),
loader_config=loader_config,
)
docs = await loader.aload()
urls = [
doc.metadata.get('source') for doc in docs if doc.metadata.get('source')
] # only keep the urls returned by the loader
url_set = set(urls)
result_items = [
dict(item) for item in result_items if item.link in urls
dict(item) for item in result_items if item.link in url_set
] # only keep the search results that have been loaded
if config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
@ -2640,7 +2676,8 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
}
else:
# Create a single collection for all documents
collection_name = f'web-search-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
# Bind the ephemeral collection to its owner so filter_accessible_collections can scope it per-user.
collection_name = f'web-search-{user.id}-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
try:
await run_in_threadpool(
@ -2653,7 +2690,12 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
user=user,
)
except Exception as e:
log.debug(f'error saving docs: {e}')
# Surface the failure instead of returning an unusable collection
log.exception(f'Error saving web search results to vector DB: {e}')
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to embed and store the retrieved web pages. Check the embedding configuration in Admin Settings > Documents.',
)
return {
'status': True,

View file

@ -17,7 +17,6 @@ from open_webui.events import EVENTS, publish_event
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.auth import get_verified_user
from open_webui.utils.terminals import get_terminal_server_url
@ -50,6 +49,10 @@ def _sanitize_proxy_path(path: str) -> str | None:
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
if unquote(decoded) != decoded:
return None
# posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component.
# Upstreams that treat '\' as a separator would resolve it, so reject outright.
if '\\' in decoded:
return None
had_trailing_slash = decoded.endswith('/')
normalized = posixpath.normpath(decoded)
# Remove any leading slashes that would reset the base
@ -97,6 +100,9 @@ async def proxy_terminal(
if connection is None:
return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404)
if not connection.get('enabled', True):
return JSONResponse({'error': 'Terminal server disabled'}, status_code=403)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
return JSONResponse({'error': 'Access denied'}, status_code=403)
@ -129,9 +135,18 @@ async def proxy_terminal(
headers.update(bearer_auth_header(request.state.token.credentials))
elif auth_type == 'system_oauth':
cookies = request.cookies
oauth_token = request.headers.get('x-oauth-access-token', '')
# Resolve the token server-side from the caller's OAuth session; never trust a client header.
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
if oauth_token:
headers.update(bearer_auth_header(oauth_token))
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
# auth_type == "none": no Authorization header
content_type = request.headers.get('content-type')
@ -208,7 +223,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
import asyncio
import json
from open_webui.utils.auth import decode_token, is_valid_token
from open_webui.utils.auth import get_verified_user_by_token
# First-message authentication
try:
@ -217,14 +232,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
if payload.get('type') != 'auth':
await ws.close(code=4001, reason='Expected auth message')
return None
token = payload.get('token', '')
data = decode_token(token)
if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)):
await ws.close(code=4001, reason='Invalid token')
return None
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
if user is None:
await ws.close(code=4001, reason='User not found')
await ws.close(code=4001, reason='Invalid token')
return None
except (asyncio.TimeoutError, json.JSONDecodeError):
await ws.close(code=4001, reason='Auth timeout or invalid payload')
@ -241,6 +251,10 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
await ws.close(code=4004, reason='Terminal server not found')
return None
if not connection.get('enabled', True):
await ws.close(code=4003, reason='Terminal server disabled')
return None
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason='Access denied')

View file

@ -446,20 +446,22 @@ async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
db=db,
)
):
return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='write',
db=db,
)
),
write_access = (
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='write',
db=db,
)
)
data = tools.model_dump()
if not write_access:
# extra='allow' re-admits content from model_dump; source is writer-only
data.pop('content', None)
return ToolAccessResponse(**data, write_access=write_access)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -1069,36 +1069,41 @@ async def get_user_preview(
user_group_ids = {g.id for g in user_groups}
all_models = await Models.get_all_models(db=db)
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(
active_models = [m for m in all_models if m.is_active]
owned_model_ids = {m.id for m in active_models if m.user_id == user_id}
granted_model_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='model',
resource_ids=[m.id for m in all_models],
resource_ids=[m.id for m in active_models if m.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
accessible_model_ids = owned_model_ids | granted_model_ids
all_knowledge = await Knowledges.get_knowledge_bases(db=db)
accessible_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
owned_knowledge_ids = {k.id for k in all_knowledge if k.user_id == user_id}
granted_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='knowledge',
resource_ids=[k.id for k in all_knowledge],
resource_ids=[k.id for k in all_knowledge if k.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
accessible_knowledge_ids = owned_knowledge_ids | granted_knowledge_ids
all_tools = await Tools.get_tools(defer_content=True, db=db)
accessible_tool_ids = await AccessGrants.get_accessible_resource_ids(
owned_tool_ids = {t.id for t in all_tools if t.user_id == user_id}
granted_tool_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='tool',
resource_ids=[t.id for t in all_tools],
resource_ids=[t.id for t in all_tools if t.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
active_models = [m for m in all_models if m.is_active]
accessible_tool_ids = owned_tool_ids | granted_tool_ids
return {
'user': {'id': target_user.id, 'name': target_user.name},

View file

@ -31,12 +31,16 @@ from open_webui.env import (
from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.models.notes import Notes, NoteUpdateForm
from open_webui.models.users import UserNameResponse, Users
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
from open_webui.tasks import create_task, stop_item_tasks
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import decode_token, is_valid_token
from open_webui.utils.auth import get_verified_user_by_token
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.json_codec import SOCKETIO_JSON
from open_webui.utils.misc import get_output_text
from open_webui.utils.redis import (
build_sentinel_url,
get_redis_connection,
@ -60,29 +64,6 @@ def get_room_sid_map(manager, namespace: str, room: str):
return manager.rooms.get(namespace, {}).get(room)
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
Every instance subscribed to the shared Socket.IO channel receives every
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
the full packet before discovering the target room has no participants on
this instance, so each instance burns CPU serializing payloads addressed
to sessions it does not host — a cost that grows with instance count.
Bail out before that work when the room is empty here. Only string
rooms take the fast path; broadcasts (``room=None``) and any other
room shape (e.g. lists, which upstream indexes before validating)
always pass through unchanged.
"""
async def _handle_emit(self, message):
room = message.get('room')
if isinstance(room, str):
namespace = message.get('namespace') or '/'
if not get_room_sid_map(self, namespace, room):
return
await super()._handle_emit(message)
if WEBSOCKET_MANAGER == 'redis':
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
ws_redis_url = (
@ -90,10 +71,11 @@ if WEBSOCKET_MANAGER == 'redis':
if sentinel_hosts
else WEBSOCKET_REDIS_URL
)
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS, json=SOCKETIO_JSON)
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
json=SOCKETIO_JSON,
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
@ -107,6 +89,7 @@ else:
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
json=SOCKETIO_JSON,
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
@ -192,25 +175,31 @@ YDOC_MANAGER = YdocManager(
async def periodic_session_pool_cleanup():
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
if not session_aquire_func():
log.debug('Session cleanup lock held by another node. Skipping.')
return
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
while True:
if not session_aquire_func():
log.debug('Session cleanup lock held by another node. Retrying.')
await asyncio.sleep(retry_delay)
continue
try:
while True:
if not session_renew_func():
log.error('Unable to renew session cleanup lock. Exiting.')
return
try:
while True:
if not session_renew_func():
log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.')
break
now = int(time.time())
for sid in list(SESSION_POOL.keys()):
entry = SESSION_POOL.get(sid)
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
del SESSION_POOL[sid]
await asyncio.sleep(SESSION_POOL_TIMEOUT)
finally:
session_release_func()
now = int(time.time())
for sid in list(SESSION_POOL.keys()):
entry = SESSION_POOL.get(sid)
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
try:
del SESSION_POOL[sid]
except KeyError:
pass
await asyncio.sleep(SESSION_POOL_TIMEOUT)
finally:
session_release_func()
async def periodic_usage_pool_cleanup():
@ -368,13 +357,10 @@ async def connect(sid, environ, auth):
scope = (environ or {}).get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
data = decode_token(auth['token'])
if data is not None and 'id' in data and await is_valid_token(data, redis):
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if user:
SESSION_POOL[sid] = {
socket_user = {
**user.model_dump(
exclude=[
'profile_image_url',
@ -386,6 +372,8 @@ async def connect(sid, environ, auth):
),
'last_seen_at': int(time.time()),
}
SESSION_POOL[sid] = socket_user
await sio.save_session(sid, {'user': socket_user})
await sio.enter_room(sid, f'user:{user.id}')
@ -399,42 +387,35 @@ async def user_join(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
token_data = decode_token(auth['token'])
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
user = await get_verified_user_by_token(auth['token'], redis)
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
socket_user = {
**user.model_dump(
exclude=[
'profile_image_url',
'profile_banner_image_url',
'date_of_birth',
'bio',
'gender',
]
),
'last_seen_at': int(time.time()),
}
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
SESSION_POOL[sid] = socket_user
await sio.save_session(sid, {'user': socket_user})
await sio.enter_room(sid, f'user:{user.id}')
# Join all the channels only if user has channels permission
if user_role == 'admin' or await has_permission(user_id, 'features.channels'):
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')
@ -455,11 +436,7 @@ async def join_channel(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
data = decode_token(auth['token'])
if data is None or 'id' not in data or not await is_valid_token(data, redis):
return
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if not user:
return
@ -481,11 +458,7 @@ async def join_note(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
token_data = decode_token(auth['token'])
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
return
user = await Users.get_user_by_id(token_data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if not user:
return
@ -540,9 +513,35 @@ async def channel_events(sid, data):
await Channels.update_member_last_read_at(data['channel_id'], user['id'])
async def get_folder_unread_counts(user_id: str) -> dict[str, int]:
folder_list = await Folders.get_folders_by_user_id(user_id)
parent_by_id = {folder.id: folder.parent_id for folder in folder_list}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()))
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
@sio.on('events:chat')
async def chat_events(sid, data):
user = SESSION_POOL.get(sid)
try:
session = await sio.get_session(sid)
user = session.get('user')
except KeyError:
user = None
if not user:
user = SESSION_POOL.get(sid)
if not user:
return
@ -550,11 +549,32 @@ async def chat_events(sid, data):
event_type = event_data.get('type')
if event_type == 'last_read_at':
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
if not read_update:
return
last_read_at, was_unread = read_update
response_data = {
'chat_id': data['chat_id'],
'last_read_at': last_read_at,
}
if was_unread:
response_data['folder_unread_counts'] = await get_folder_unread_counts(user['id'])
await sio.emit(
'events',
{
'chat_id': data['chat_id'],
'data': {
'type': 'chat:list',
'data': response_data,
},
},
room=f'user:{user["id"]}',
)
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(data['chat_id'], 'chat.read')
await cancel_timers_for_chat(data['chat_id'], 'chat.read', user['id'])
except Exception:
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
@ -887,19 +907,20 @@ async def _make_channel_emitter(request_info):
state = {'last_emit_at': 0.0}
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
async def _emit_channel_update(content: str, done: bool = False):
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
from open_webui.models.messages import MessageForm, Messages
msg = await Messages.get_message_by_id(message_id)
if not msg or msg.channel_id != channel_id:
return
update_form = MessageForm(content=content)
update_form = MessageForm(content=content, data={'output': output} if output else None)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
existing_meta = msg.meta or {}
update_form = MessageForm(
content=content,
data={'output': output} if output else None,
meta={**existing_meta, 'done': True},
)
@ -924,16 +945,17 @@ async def _make_channel_emitter(request_info):
if event_type == 'chat:completion':
data = event_data.get('data', {})
content = data.get('content', '')
output = data.get('output')
content = data.get('content') or get_output_text(output)
done = data.get('done', False)
if not content and not done:
if not content and not output and not done:
return
now = __import__('time').time()
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
state['last_emit_at'] = now
await _emit_channel_update(content, done)
await _emit_channel_update(content, done, output if isinstance(output, list) else None)
elif event_type == 'chat:message:error':
error = event_data.get('data', {}).get('error', {})
@ -953,6 +975,7 @@ async def get_event_emitter(request_info, update_db=True):
chat_id = request_info['chat_id']
message_id = request_info['message_id']
internal = request_info.get('internal') is True
save_to_chat = update_db and message_id and is_saved_chat_id(chat_id)
if internal and event_data.get('type') == 'notification':
return
@ -971,7 +994,7 @@ async def get_event_emitter(request_info, update_db=True):
room=room,
)
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
if save_to_chat:
event_type = event_data.get('type')
if event_type == 'status':
@ -1097,6 +1120,11 @@ async def get_event_call(request_info):
)
except TimeoutError:
log.warning(f'Event caller timed out for session {session_id}')
if SESSION_POOL.get(session_id) == session:
try:
del SESSION_POOL[session_id]
except KeyError:
pass
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:

View file

@ -3,12 +3,12 @@
from __future__ import annotations
import hashlib
import json
import uuid
import pycrdt as Y
from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_KEY_PREFIX
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.redis import get_redis_connection
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
@ -16,6 +16,19 @@ YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
class RedisLock:
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
_RENEW_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
"""
_RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
"""
def __init__(
self,
redis_url,
@ -41,13 +54,10 @@ class RedisLock:
return self.lock_obtained
def renew_lock(self):
# xx=True will only set this key if it _has_ already been set
return self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs)
return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs))
def release_lock(self):
lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value == self.lock_id:
self.redis.delete(self.lock_name)
self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id)
class RedisDict:
@ -65,14 +75,14 @@ class RedisDict:
)
def __setitem__(self, key, value):
serialized_value = json.dumps(value)
serialized_value = JSONCodec.dumps(value)
self.redis.hset(self.name, key, serialized_value)
def __getitem__(self, key):
value = self.redis.hget(self.name, key)
if value is None:
raise KeyError(key)
return json.loads(value)
return JSONCodec.loads(value)
def __delitem__(self, key):
result = self.redis.hdel(self.name, key)
@ -89,10 +99,10 @@ class RedisDict:
return self.redis.hkeys(self.name)
def values(self):
return [json.loads(v) for v in self.redis.hvals(self.name)]
return [JSONCodec.loads(v) for v in self.redis.hvals(self.name)]
def items(self):
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
def set(self, mapping: dict):
if not mapping:
@ -101,7 +111,7 @@ class RedisDict:
return
# Serialize values once — reused for both the fingerprint and the write.
serialized = {k: json.dumps(v) for k, v in mapping.items()}
serialized = {k: JSONCodec.dumps(v) for k, v in mapping.items()}
digest = hashlib.sha256()
for key in sorted(serialized):
digest.update(key.encode())
@ -172,7 +182,7 @@ class YdocManager:
document_id = document_id.replace(':', '_')
if self._redis:
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
await self._redis.rpush(redis_key, json.dumps(list(update)))
await self._redis.rpush(redis_key, JSONCodec.dumps(list(update)))
list_len = await self._redis.llen(redis_key)
if list_len >= self.COMPACTION_THRESHOLD:
await self._compact_updates_redis(document_id)
@ -192,8 +202,8 @@ class YdocManager:
mid = len(all_updates) // 2
ydoc = Y.Doc()
for raw in all_updates[:mid]:
ydoc.apply_update(bytes(json.loads(raw)))
snapshot = json.dumps(list(ydoc.get_update()))
ydoc.apply_update(bytes(JSONCodec.loads(raw)))
snapshot = JSONCodec.dumps(list(ydoc.get_update()))
pipe = self._redis.pipeline()
pipe.delete(redis_key)
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
@ -216,7 +226,7 @@ class YdocManager:
if self._redis:
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
updates = await self._redis.lrange(redis_key, 0, -1)
return [bytes(json.loads(update)) for update in updates]
return [bytes(JSONCodec.loads(update)) for update in updates]
else:
return self._updates.get(document_id, [])

View file

@ -2,10 +2,8 @@
import asyncio
import json
import logging
from typing import Dict, List, Optional
from uuid import uuid4
from fastapi import Request
from redis.asyncio import Redis
from open_webui.env import REDIS_KEY_PREFIX
@ -13,7 +11,7 @@ from open_webui.env import REDIS_KEY_PREFIX
log = logging.getLogger(__name__)
# A dictionary to keep track of active tasks
tasks: Dict[str, asyncio.Task] = {}
tasks: dict[str, asyncio.Task] = {}
item_tasks = {}
@ -46,7 +44,7 @@ async def redis_task_command_listener(app):
### ------------------------------
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
pipe = redis.pipeline()
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '')
if item_id:
@ -54,7 +52,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
await pipe.execute()
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
pipe = redis.pipeline()
pipe.hdel(REDIS_TASKS_KEY, task_id)
if item_id:
@ -67,11 +65,11 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str])
await pipe.execute()
async def redis_list_tasks(redis: Redis) -> List[str]:
async def redis_list_tasks(redis: Redis) -> list[str]:
return list(await redis.hkeys(REDIS_TASKS_KEY))
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]:
return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}'))
@ -101,11 +99,11 @@ async def cleanup_task(redis, task_id: str, id=None):
item_tasks.pop(id, None)
async def create_task(redis, coroutine, id=None):
async def create_task(redis, coroutine, id=None, task_id=None):
"""
Create a new asyncio task and add it to the global task dictionary.
"""
task_id = str(uuid4()) # Generate a unique ID for the task
task_id = task_id or str(uuid4()) # Generate a unique ID for the task
task = asyncio.create_task(coroutine) # Create the task
# Add a done callback for cleanup

View file

@ -14,8 +14,14 @@ import logging
import time
from typing import Literal, Optional
from fastapi import Request
from fastapi import HTTPException, Request
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.env import (
KNOWLEDGE_GREP_MAX_MATCHES,
VIEW_FILE_DEFAULT_MAX_CHARS,
VIEW_FILE_MAX_CHARS,
)
from open_webui.models.channels import Channel, ChannelMember, Channels
from open_webui.models.chats import Chats
from open_webui.models.config import Config
@ -52,6 +58,7 @@ from open_webui.routers.retrieval import search_web as _search_web
from open_webui.tasks import stop_item_tasks
from open_webui.events import EVENTS, publish_event
from open_webui.socket.main import sio
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.notifications import notify_target
from open_webui.utils.sanitize import sanitize_code
@ -378,7 +385,7 @@ async def generate_image(
image_files = [{'type': 'image', 'url': img['url']} for img in images]
# Persist files to DB if chat context is available
if __chat_id__ and __message_id__ and images:
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
db_files = await Chats.add_message_files_by_id_and_message_id(
__chat_id__,
__message_id__,
@ -446,7 +453,7 @@ async def edit_image(
image_files = [{'type': 'image', 'url': img['url']} for img in images]
# Persist files to DB if chat context is available
if __chat_id__ and __message_id__ and images:
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
db_files = await Chats.add_message_files_by_id_and_message_id(
__chat_id__,
__message_id__,
@ -2155,10 +2162,307 @@ async def search_knowledge_files(
return json.dumps({'error': str(e)})
# Hard cap for view_file / view_knowledge_file output
MAX_VIEW_FILE_CHARS = 100_000
DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
MAX_GREP_RESULTS = 50
async def _get_accessible_chat_files(
files: Optional[list[dict]],
user: dict,
file_id: Optional[str] = None,
) -> list[tuple[dict, object]]:
from open_webui.models.files import Files
user_id = user.get('id')
user_role = user.get('role', 'user')
accessible = []
seen = set()
for item in files or []:
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
continue
fid = item.get('id') or item.get('url') or ''
if (
not isinstance(fid, str)
or not fid
or fid in seen
or fid.startswith(('http://', 'https://', 'data:'))
or (file_id and fid != file_id)
):
continue
normalized = {**item, 'id': fid, 'type': 'file'}
if 'name' not in normalized and item.get('filename'):
normalized['name'] = item.get('filename')
seen.add(fid)
file = await Files.get_file_by_id(fid)
if file and await _has_read_access_to_file(file, user_id, user_role):
accessible.append((normalized, file))
return accessible
def _grep_file_models(
files_to_search: list,
pattern: str,
case_insensitive: bool = False,
count_only: bool = False,
) -> str:
from open_webui.tools.knowledge_fs import build_matcher
matches, err = build_matcher(pattern, case_insensitive)
if err:
return json.dumps({'error': err})
results = []
total_matches = 0
counts = []
for file in files_to_search:
content = ''
if file.data:
content = file.data.get('content', '')
if not content:
continue
lines = content.split('\n')
file_matches = 0
for i, line in enumerate(lines, 1):
if matches(line):
file_matches += 1
total_matches += 1
if not count_only and len(results) < KNOWLEDGE_GREP_MAX_MATCHES:
results.append(f'{file.id} {file.filename}:{i}: {line}')
if file_matches > 0 and count_only:
counts.append(f'{file.id} {file.filename}: {file_matches}')
if count_only:
if not counts:
return f'No matches for "{pattern}"'
return '\n'.join(counts) + f'\n[{total_matches} total matches]'
if not results:
return f'No matches for "{pattern}"'
output = '\n'.join(results)
if total_matches > KNOWLEDGE_GREP_MAX_MATCHES:
output += f'\n[{KNOWLEDGE_GREP_MAX_MATCHES} of {total_matches} matches shown — use file_id to narrow]'
return output
async def list_chat_files(
__request__: Request = None,
__user__: dict = None,
__files__: list[dict] = None,
) -> str:
"""
List files attached to the current chat.
:return: JSON with attached chat files containing id, filename, content type, size, and updated time when available
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
files = []
for item, file in await _get_accessible_chat_files(__files__, __user__):
file_info = {
'id': file.id,
'filename': file.filename,
'name': item.get('name') or file.filename,
'type': item.get('type', 'file'),
'updated_at': file.updated_at,
}
content_type = item.get('content_type') or (file.meta or {}).get('content_type')
size = item.get('size') or (file.meta or {}).get('size')
if content_type:
file_info['content_type'] = content_type
if size:
file_info['size'] = size
files.append(file_info)
return json.dumps(files, ensure_ascii=False)
except Exception as e:
log.exception(f'list_chat_files error: {e}')
return json.dumps({'error': str(e)})
async def grep_chat_files(
pattern: str,
file_id: Optional[str] = None,
case_insensitive: bool = False,
count_only: bool = False,
__request__: Request = None,
__user__: dict = None,
__files__: list[dict] = None,
) -> str:
"""
Search exact text across files attached to the current chat.
Pass file_id from the attached_files block to search one file.
:param pattern: The text pattern to search for
:param file_id: Optional attached file ID to search within a single file
:param case_insensitive: If true, ignore case when matching
:param count_only: If true, return only match counts per file
:return: Matching lines with file IDs, filenames, and line numbers
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
if not pattern or not pattern.strip():
return json.dumps({'error': 'Pattern is required'})
if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''):
file_id = None
try:
attached_ids = set()
for item in __files__ or []:
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
continue
fid = item.get('id') or item.get('url')
if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')):
attached_ids.add(fid)
if not attached_ids:
return json.dumps({'error': 'No files are attached to this chat'})
if file_id and file_id not in attached_ids:
return json.dumps({'error': 'File not found'})
files_to_search = [file for _, file in await _get_accessible_chat_files(__files__, __user__, file_id)]
if not files_to_search:
return json.dumps({'error': 'No accessible files found'})
return _grep_file_models(files_to_search, pattern, case_insensitive, count_only)
except Exception as e:
log.exception(f'grep_chat_files error: {e}')
return json.dumps({'error': str(e)})
async def query_chat_files(
query: str,
file_id: Optional[str] = None,
count: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
__files__: list[dict] = None,
) -> str:
"""
Search files attached to the current chat using semantic/vector search.
Pass file_id from the attached_files block to search one file, or omit it to search all attached chat files.
:param query: The search query to find semantically relevant content
:param file_id: Optional attached file ID to search within a single file
:param count: Maximum number of results to return, capped by the server RAG top k
:return: JSON with relevant chunks containing content, source filename, and relevance score
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''):
file_id = None
if isinstance(count, str):
if count.lower() in ('none', 'null', ''):
count = None
else:
try:
count = int(count)
except ValueError:
count = None
try:
from open_webui.retrieval.utils import get_sources_from_items
attached_ids = set()
for item in __files__ or []:
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
continue
fid = item.get('id') or item.get('url')
if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')):
attached_ids.add(fid)
if not attached_ids:
return json.dumps({'error': 'No files are attached to this chat'})
if file_id and file_id not in attached_ids:
return json.dumps({'error': 'File not found'})
accessible = await _get_accessible_chat_files(__files__, __user__, file_id)
if not accessible:
return json.dumps({'error': 'No accessible files found'})
file_items = [{**item} for item, _ in accessible]
rag_config = await Config.get_many(
'rag.top_k',
'rag.top_k_reranker',
'rag.relevance_threshold',
'rag.hybrid_bm25_weight',
'rag.enable_hybrid_search',
'rag.full_context',
)
top_k = rag_config.get('rag.top_k') or 5
count = top_k if count is None else max(1, min(count, top_k))
full_context = all(item.get('context') == 'full' for item in file_items) or rag_config.get('rag.full_context')
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function and not full_context:
return json.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(
id=__user__.get('id'),
role=__user__.get('role', 'user'),
)
sources = await get_sources_from_items(
request=__request__,
items=file_items,
queries=[query],
embedding_function=(
lambda queries, prefix: embedding_function(queries, prefix=prefix, user=user_model)
if embedding_function
else None
),
k=count,
reranking_function=(
(lambda q, docs: __request__.app.state.RERANKING_FUNCTION(q, docs, user=user_model))
if getattr(__request__.app.state, 'RERANKING_FUNCTION', None)
else None
),
k_reranker=rag_config.get('rag.top_k_reranker'),
r=rag_config.get('rag.relevance_threshold'),
hybrid_bm25_weight=rag_config.get('rag.hybrid_bm25_weight'),
hybrid_search=rag_config.get('rag.enable_hybrid_search'),
full_context=full_context,
user=user_model,
)
chunks = []
for source in sources or []:
documents = source.get('document') or []
metadatas = source.get('metadata') or []
distances = source.get('distances') or []
source_info = source.get('source') or {}
for idx, doc in enumerate(documents):
metadata = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
chunk = {
'content': doc,
'source': metadata.get('source', metadata.get('name', source_info.get('name', 'Unknown'))),
'file_id': metadata.get('file_id', source_info.get('id', '')),
}
if idx < len(distances):
chunk['distance'] = distances[idx]
chunks.append(chunk)
return json.dumps(chunks[:count], ensure_ascii=False)
except Exception as e:
log.exception(f'query_chat_files error: {e}')
return json.dumps({'error': str(e)})
async def grep_knowledge_files(
@ -2194,16 +2498,11 @@ async def grep_knowledge_files(
try:
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.tools.knowledge_fs import build_matcher
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
_matches, err = build_matcher(pattern, case_insensitive)
if err:
return json.dumps({'error': err})
# Collect files to search
files_to_search = []
@ -2279,43 +2578,7 @@ async def grep_knowledge_files(
if not files_to_search:
return json.dumps({'error': 'No accessible files found'})
# Search
results = []
total_matches = 0
counts = []
for file in files_to_search:
content = ''
if file.data:
content = file.data.get('content', '')
if not content:
continue
lines = content.split('\n')
file_matches = 0
for i, line in enumerate(lines, 1):
if _matches(line):
file_matches += 1
total_matches += 1
if not count_only and len(results) < MAX_GREP_RESULTS:
results.append(f'{file.id} {file.filename}:{i}: {line}')
if file_matches > 0 and count_only:
counts.append(f'{file.id} {file.filename}: {file_matches}')
if count_only:
if not counts:
return f'No matches for "{pattern}"'
return '\n'.join(counts) + f'\n[{total_matches} total matches]'
if not results:
return f'No matches for "{pattern}"'
output = '\n'.join(results)
if total_matches > MAX_GREP_RESULTS:
output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]'
return output
return _grep_file_models(files_to_search, pattern, case_insensitive, count_only)
except Exception as e:
log.exception(f'grep_knowledge_files error: {e}')
@ -2325,7 +2588,7 @@ async def grep_knowledge_files(
async def view_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
max_chars: int = VIEW_FILE_DEFAULT_MAX_CHARS,
line_numbers: bool = False,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
@ -2338,7 +2601,7 @@ async def view_file(
:param file_id: The ID of the file to retrieve
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:param max_chars: Maximum characters to return (a server-side hard cap applies)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
@ -2360,10 +2623,10 @@ async def view_file(
try:
max_chars = int(max_chars)
except ValueError:
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
max_chars = VIEW_FILE_DEFAULT_MAX_CHARS
# Enforce hard cap
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
max_chars = min(max(max_chars, 1), VIEW_FILE_MAX_CHARS)
offset = max(offset, 0)
try:
@ -2441,7 +2704,7 @@ async def view_file(
async def view_knowledge_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
max_chars: int = VIEW_FILE_DEFAULT_MAX_CHARS,
line_numbers: bool = False,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
@ -2453,7 +2716,7 @@ async def view_knowledge_file(
:param file_id: The ID of the file to retrieve
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:param max_chars: Maximum characters to return (a server-side hard cap applies)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
@ -2475,10 +2738,10 @@ async def view_knowledge_file(
try:
max_chars = int(max_chars)
except ValueError:
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
max_chars = VIEW_FILE_DEFAULT_MAX_CHARS
# Enforce hard cap
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
max_chars = min(max(max_chars, 1), VIEW_FILE_MAX_CHARS)
offset = max(offset, 0)
try:
@ -2781,9 +3044,10 @@ async def query_knowledge_files(
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
embedding_function = __request__.app.state.EMBEDDING_FUNCTION
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
return json.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(id=user_id, role=user_role)
collection_names = []
external_knowledges = []
@ -2891,7 +3155,9 @@ async def query_knowledge_files(
__request__,
collection_names=collection_names,
queries=[query],
embedding_function=embedding_function,
embedding_function=lambda queries, prefix: embedding_function(
queries, prefix=prefix, user=user_model
),
k=count,
)
@ -2916,7 +3182,7 @@ async def query_knowledge_files(
knowledge,
queries=[query],
count=count,
user=type('UserContext', (), {'id': user_id, 'role': user_role})(),
user=user_model,
)
documents = query_results.get('documents', [[]])[0]
metadatas = query_results.get('metadatas', [[]])[0]
@ -2974,7 +3240,11 @@ async def query_knowledge_bases(
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
query_embedding = await __request__.app.state.EMBEDDING_FUNCTION(query)
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
return json.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(id=user_id, role=__user__.get('role', 'user'))
query_embedding = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user_model)
# Min-heap of (distance, knowledge_base_id) - only holds top `count` results
top_results_heap = []
@ -3164,8 +3434,8 @@ async def create_tasks(
:param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated).
:return: JSON with the full task list and summary counts
"""
if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'})
if not is_saved_chat_id(__chat_id__):
return json.dumps({'error': 'Saved chat context not available'})
try:
all_tasks = []
@ -3216,8 +3486,8 @@ async def update_task(
:param status: New status: completed, in_progress, pending, or cancelled (default: completed)
:return: JSON with the updated task list and summary counts
"""
if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'})
if not is_saved_chat_id(__chat_id__):
return json.dumps({'error': 'Saved chat context not available'})
try:
status = status.strip().lower()
@ -3255,10 +3525,22 @@ async def update_task(
# =============================================================================
async def _validate_owned_automation_folder(user_id: str, folder_id: Optional[str]) -> Optional[str]:
if not folder_id:
return None
from open_webui.models.folders import Folders
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user_id)
if not folder:
raise ValueError('Folder not found')
return folder.id
async def create_automation(
name: str,
prompt: str,
rrule: str,
folder_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
@ -3281,6 +3563,7 @@ async def create_automation(
:param name: A short descriptive name for the automation
:param prompt: The prompt/instructions to execute on each run
:param rrule: An iCalendar RRULE string defining the schedule
:param folder_id: Optional owner-owned folder ID for generated chats
:return: JSON with the created automation details including id, next scheduled runs
"""
if __request__ is None:
@ -3292,6 +3575,7 @@ async def create_automation(
try:
from open_webui.models.automations import AutomationData, AutomationForm, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
user_id = __user__.get('id')
@ -3307,15 +3591,26 @@ async def create_automation(
if not model_id:
return json.dumps({'error': 'Could not detect current model'})
try:
folder_id = await _validate_owned_automation_folder(user_id, folder_id)
except ValueError as e:
return json.dumps({'error': str(e)})
# Validate the RRULE
try:
validate_rrule(rrule, tz=user.timezone)
except ValueError as e:
return json.dumps({'error': f'Invalid schedule: {e}'})
try:
await check_automation_limits(__request__, user, rrule, None, is_create=True)
except HTTPException as e:
return json.dumps({'error': e.detail})
tz = user.timezone
form = AutomationForm(
name=name,
folder_id=folder_id,
data=AutomationData(
prompt=prompt,
model_id=model_id,
@ -3331,6 +3626,7 @@ async def create_automation(
'status': 'success',
'id': automation.id,
'name': automation.name,
'folder_id': automation.folder_id,
'model_id': model_id,
'is_active': automation.is_active,
'next_runs': next_n_runs_ns(rrule, tz=tz),
@ -3348,6 +3644,7 @@ async def update_automation(
prompt: Optional[str] = None,
rrule: Optional[str] = None,
model_id: Optional[str] = None,
folder_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
@ -3359,6 +3656,7 @@ async def update_automation(
:param prompt: New prompt/instructions (optional)
:param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples.
:param model_id: New model ID to use (optional)
:param folder_id: New owner-owned folder ID (optional); pass an empty string to clear
:return: JSON with the updated automation details
"""
if __request__ is None:
@ -3370,10 +3668,13 @@ async def update_automation(
try:
from open_webui.models.automations import AutomationData, AutomationForm, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
user_id = __user__.get('id')
user = await Users.get_user_by_id(user_id)
if not user:
return json.dumps({'error': 'User not found'})
automation = await Automations.get_by_id(automation_id)
if not automation:
@ -3386,17 +3687,30 @@ async def update_automation(
new_prompt = prompt if prompt is not None else automation.data.get('prompt', '')
new_model_id = model_id if model_id is not None else automation.data.get('model_id', '')
new_rrule = rrule if rrule is not None else automation.data.get('rrule', '')
if folder_id is None:
new_folder_id = automation.folder_id
else:
try:
new_folder_id = await _validate_owned_automation_folder(user_id, folder_id)
except ValueError as e:
return json.dumps({'error': str(e)})
# Validate RRULE if changed
if rrule is not None:
try:
validate_rrule(new_rrule, tz=user.timezone if user else None)
validate_rrule(new_rrule, tz=user.timezone)
except ValueError as e:
return json.dumps({'error': f'Invalid schedule: {e}'})
tz = user.timezone if user else None
try:
await check_automation_limits(__request__, user, new_rrule, None)
except HTTPException as e:
return json.dumps({'error': e.detail})
tz = user.timezone
form = AutomationForm(
name=new_name,
folder_id=new_folder_id,
data=AutomationData(
prompt=new_prompt,
model_id=new_model_id,
@ -3412,6 +3726,7 @@ async def update_automation(
'status': 'success',
'id': updated.id,
'name': updated.name,
'folder_id': updated.folder_id,
'model_id': new_model_id,
'is_active': updated.is_active,
'next_runs': next_n_runs_ns(new_rrule, tz=tz),
@ -3425,6 +3740,7 @@ async def update_automation(
async def list_automations(
status: Optional[str] = None,
folder_id: Optional[str] = None,
count: int = 10,
__request__: Request = None,
__user__: dict = None,
@ -3433,6 +3749,7 @@ async def list_automations(
List the user's scheduled automations.
:param status: Filter by status: "active", "paused", or omit for all
:param folder_id: Optional owner-owned folder ID filter; pass an empty string to clear the folder filter
:param count: Maximum number of automations to return (default: 10)
:return: JSON list of automations with id, name, prompt snippet, schedule, status, and next runs
"""
@ -3449,10 +3766,16 @@ async def list_automations(
user_id = __user__.get('id')
user = await Users.get_user_by_id(user_id)
if folder_id:
try:
folder_id = await _validate_owned_automation_folder(user_id, folder_id)
except ValueError as e:
return json.dumps({'error': str(e)})
result = await Automations.search_automations(
user_id=user_id,
status=status,
folder_id=folder_id,
skip=0,
limit=count,
)
@ -3467,6 +3790,7 @@ async def list_automations(
{
'id': item.id,
'name': item.name,
'folder_id': item.folder_id,
'prompt_snippet': snippet,
'model_id': item.data.get('model_id', ''),
'rrule': rrule,

View file

@ -7,24 +7,58 @@ for AI models to interact with knowledge bases using commands they already know.
Re-exported through builtin.py for consistent imports.
"""
import contextvars
import json
import logging
import re
import shlex
import time
from contextlib import contextmanager
from typing import Optional
import regex
from fastapi import Request
from open_webui.env import (
KB_EXEC_MAX_GREP_FILES,
KB_EXEC_MAX_OUTPUT_CHARS,
KNOWLEDGE_GREP_MAX_MATCHES,
)
log = logging.getLogger(__name__)
# Limits
MAX_CAT_CHARS = 100_000
DEFAULT_CAT_CHARS = 10_000
MAX_GREP_FILES = 200
DEFAULT_HEAD_LINES = 10
DEFAULT_TAIL_LINES = 10
MAX_GREP_MATCHES = 50
# Matching time allowed per tool call. Backtracking cost is exponential in the length of the
# matched text, so capping the pattern or the line does not bound it.
MATCH_BUDGET_SECONDS = 2.0
class MatchBudgetExceeded(Exception):
"""A tool call spent its whole matching budget, so the caller reports it."""
class MatchBudget:
"""Matching time remaining, counted only inside search() so awaits do not consume it."""
def __init__(self):
self.remaining = MATCH_BUDGET_SECONDS
# Scoped to the running task, so one budget covers every matcher a command builds without
# threading it through each handler.
_active_budget: contextvars.ContextVar[MatchBudget | None] = contextvars.ContextVar('kb_match_budget', default=None)
@contextmanager
def match_budget():
"""Bound the matching time of one tool call rather than of each search it runs."""
token = _active_budget.set(MatchBudget())
try:
yield
finally:
_active_budget.reset(token)
# =============================================================================
@ -59,11 +93,26 @@ def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool
if use_regex:
normalized = normalize_regex(pattern)
try:
re_flags = re.IGNORECASE if case_insensitive else 0
compiled = re.compile(normalized, re_flags)
except re.error as e:
re_flags = regex.IGNORECASE if case_insensitive else 0
compiled = regex.compile(normalized, re_flags)
except regex.error as e:
return None, f'Invalid regex: {e}'
return (lambda line: bool(compiled.search(line))), None
budget = _active_budget.get() or MatchBudget()
def matches(line: str) -> bool:
started = time.monotonic()
try:
# A negative timeout disables it, so an exhausted budget must not reach search().
if budget.remaining <= 0:
raise TimeoutError
return bool(compiled.search(line, timeout=budget.remaining))
except TimeoutError:
raise MatchBudgetExceeded(f'Search exceeded {MATCH_BUDGET_SECONDS:g}s, narrow the pattern') from None
finally:
budget.remaining -= time.monotonic() - started
return matches, None
else:
sp = pattern.lower() if case_insensitive else pattern
return (lambda line: sp in (line.lower() if case_insensitive else line)), None
@ -579,21 +628,10 @@ async def _kb_cat(args: list[str], flags: set[str], user: dict, model_knowledge:
return resolved['error']
content = resolved['content']
show_numbers = 'n' in flags
if len(content) > MAX_CAT_CHARS:
content = content[:MAX_CAT_CHARS]
truncated = True
else:
truncated = False
if show_numbers:
if 'n' in flags:
lines = content.split('\n')
content = '\n'.join(f'{i}: {line}' for i, line in enumerate(lines, 1))
if truncated:
content += f'\n[truncated at {MAX_CAT_CHARS:,} chars — use head/tail/sed/grep to navigate]'
return content
@ -742,7 +780,7 @@ async def _kb_grep(
if ext_filter:
accessible = [f for f in accessible if f['filename'].endswith(f'.{ext_filter}')]
if len(accessible) > MAX_GREP_FILES:
if len(accessible) > KB_EXEC_MAX_GREP_FILES:
return f'Too many files ({len(accessible)}). Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py'
from open_webui.models.files import Files
@ -774,7 +812,7 @@ async def _kb_grep(
if not count_only and not filenames_only:
for line_num, line_text in file_matches:
if len(results) < MAX_GREP_MATCHES:
if len(results) < KNOWLEDGE_GREP_MAX_MATCHES:
results.append(f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}')
if count_only:
@ -793,8 +831,8 @@ async def _kb_grep(
return f'No matches for "{pattern}" across {len(accessible)} files'
output = '\n'.join(results)
if total_matches > MAX_GREP_MATCHES:
output += f'\n[showing {MAX_GREP_MATCHES} of {total_matches} matches]'
if total_matches > KNOWLEDGE_GREP_MAX_MATCHES:
output += f'\n[showing {KNOWLEDGE_GREP_MAX_MATCHES} of {total_matches} matches]'
return output
@ -1131,7 +1169,15 @@ async def kb_exec(
if not segments:
return 'Could not parse command. Run kb_exec("ls") to start.'
return await _execute_pipeline(segments, __user__, __model_knowledge__)
# One budget for the whole command: a per-search budget would multiply by segment count.
with match_budget():
output = await _execute_pipeline(segments, __user__, __model_knowledge__)
if len(output) > KB_EXEC_MAX_OUTPUT_CHARS:
output = output[:KB_EXEC_MAX_OUTPUT_CHARS] + (
f'\n[output truncated at {KB_EXEC_MAX_OUTPUT_CHARS:,} chars'
' — narrow the command with a path, glob, head/tail/sed or grep]'
)
return output
except Exception as e:
log.exception(f'kb_exec error: {e}')
return f'Error: {e}'

View file

@ -3,9 +3,11 @@ from typing import Any
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.models.access_grants import (
has_anyone_read_access_grant,
has_public_read_access_grant,
has_public_write_access_grant,
has_user_access_grant,
strip_anyone_access_grants,
strip_user_access_grants,
)
from open_webui.models.groups import Groups
@ -161,10 +163,16 @@ async def has_connection_access(
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
return True
access_grants = (connection.get('config') or {}).get('access_grants', [])
if not access_grants:
# No grants configured → private, admin-only: admins must keep access
# to connections only they can configure, even when they do not bypass
# access control globally.
return user.role == 'admin'
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
access_grants = (connection.get('config') or {}).get('access_grants', [])
return await has_access(user.id, 'read', access_grants, user_group_ids)
@ -215,13 +223,31 @@ async def filter_allowed_access_grants(
user_role: str,
access_grants: list,
public_permission_key: str,
anyone_permission_key: str | None = None,
db: AsyncSession | None = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
if user_role == 'admin' or not access_grants:
if not access_grants:
return access_grants
if has_anyone_read_access_grant(access_grants) and (
not anyone_permission_key
or (
user_role != 'admin'
and not await has_permission(
user_id,
anyone_permission_key,
default_permissions,
db=db,
)
)
):
access_grants = strip_anyone_access_grants(access_grants)
if user_role == 'admin':
return access_grants
# Check if user can share publicly
@ -358,9 +384,7 @@ 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_role=user.role, 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':

View file

@ -126,6 +126,13 @@ def _finalize_openai_content(blocks: list) -> str | list:
return blocks
def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool:
api_config = api_config or {}
provider = str(api_config.get('provider', '')).lower()
return is_anthropic_url(url or '') or provider == 'litellm'
def convert_anthropic_to_openai_payload(
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
) -> dict:
@ -192,6 +199,8 @@ def convert_anthropic_to_openai_payload(
},
)
)
elif block_type in ('thinking', 'redacted_thinking'):
openai_content.append(_copy_cache_control(block, dict(block)))
elif block_type == 'image':
source = block.get('source', {})
if source.get('type') == 'base64':
@ -492,6 +501,11 @@ def convert_openai_to_anthropic_response(
if not isinstance(block, dict):
continue
if block.get('type') == 'redacted_thinking':
content.append({k: v for k, v in block.items() if k in {'type', 'data'}})
has_thinking = True
continue
thinking = block.get('thinking') or block.get('content') or block.get('text')
if not thinking:
continue
@ -530,19 +544,37 @@ def convert_openai_to_anthropic_response(
)
# Usage
openai_usage = openai_response.get('usage', {})
openai_usage = openai_response.get('usage') or {}
cache_creation = openai_usage.get('cache_creation_input_tokens')
cache_read = openai_usage.get('cache_read_input_tokens')
prompt_details = openai_usage.get('prompt_tokens_details')
if cache_read is None and isinstance(prompt_details, dict):
cache_read = prompt_details.get('cached_tokens')
usage_input = openai_usage.get('input_tokens')
if usage_input is None:
prompt_tokens = openai_usage.get('prompt_tokens')
if prompt_tokens is not None:
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
usage_output = openai_usage.get('output_tokens')
if usage_output is None:
usage_output = openai_usage.get('completion_tokens')
usage = {
'input_tokens': (
input_tokens
if input_tokens is not None
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0))
),
'output_tokens': openai_usage.get('output_tokens', openai_usage.get('completion_tokens', 0)),
'input_tokens': usage_input if usage_input is not None else (input_tokens if input_tokens is not None else 0),
'output_tokens': usage_output if usage_output is not None else 0,
}
if 'cache_creation_input_tokens' in openai_usage:
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
if 'cache_read_input_tokens' in openai_usage:
usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens']
if cache_creation is not None:
usage['cache_creation_input_tokens'] = cache_creation
if cache_read is not None:
usage['cache_read_input_tokens'] = cache_read
if isinstance(openai_usage.get('output_tokens_details'), dict):
usage['output_tokens_details'] = openai_usage['output_tokens_details']
if isinstance(openai_usage.get('server_tool_use'), dict):
usage['server_tool_use'] = openai_usage['server_tool_use']
if openai_usage.get('service_tier') is not None:
usage['service_tier'] = openai_usage['service_tier']
return {
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
@ -556,7 +588,7 @@ def convert_openai_to_anthropic_response(
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int = 0):
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int | None = None):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
@ -574,6 +606,11 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
output_tokens = 0
cache_creation_input_tokens = None
cache_read_input_tokens = None
output_tokens_details = None
server_tool_use = None
service_tier = None
stop_reason = 'end_turn'
# Track content blocks with a running index.
@ -603,7 +640,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
'model': model,
'stop_reason': None,
'stop_sequence': None,
'usage': {'input_tokens': input_tokens, 'output_tokens': 0},
'usage': {'input_tokens': input_tokens or 0, 'output_tokens': 0},
},
}
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
@ -630,29 +667,47 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except (json.JSONDecodeError, TypeError):
continue
usage_data = data.get('usage')
if isinstance(usage_data, dict):
cache_creation = usage_data.get('cache_creation_input_tokens')
cache_read = usage_data.get('cache_read_input_tokens')
prompt_details = usage_data.get('prompt_tokens_details')
if cache_read is None and isinstance(prompt_details, dict):
cache_read = prompt_details.get('cached_tokens')
usage_input = usage_data.get('input_tokens')
if usage_input is None:
prompt_tokens = usage_data.get('prompt_tokens')
if prompt_tokens is not None:
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
usage_output = usage_data.get('output_tokens')
if usage_output is None:
usage_output = usage_data.get('completion_tokens')
if usage_input is not None:
input_tokens = usage_input
if usage_output is not None:
output_tokens = usage_output
if cache_creation is not None:
cache_creation_input_tokens = cache_creation
if cache_read is not None:
cache_read_input_tokens = cache_read
if isinstance(usage_data.get('output_tokens_details'), dict):
output_tokens_details = usage_data['output_tokens_details']
if isinstance(usage_data.get('server_tool_use'), dict):
server_tool_use = usage_data['server_tool_use']
if usage_data.get('service_tier') is not None:
service_tier = usage_data['service_tier']
choices = data.get('choices', [])
if not choices:
# Check for usage in the final chunk
if data.get('usage'):
input_tokens = data['usage'].get(
'input_tokens', data['usage'].get('prompt_tokens', input_tokens)
)
output_tokens = data['usage'].get(
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
)
continue
delta = choices[0].get('delta', {})
finish_reason = choices[0].get('finish_reason')
message = choices[0].get('message') or {}
# Update usage if present
if data.get('usage'):
input_tokens = data['usage'].get('input_tokens', data['usage'].get('prompt_tokens', input_tokens))
output_tokens = data['usage'].get(
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
)
reasoning_content = (
delta.get('reasoning_content')
or delta.get('reasoning')
@ -892,13 +947,27 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Emit message_delta with stop reason
usage = {'output_tokens': output_tokens}
if input_tokens is not None:
usage['input_tokens'] = input_tokens
if cache_creation_input_tokens is not None:
usage['cache_creation_input_tokens'] = cache_creation_input_tokens
if cache_read_input_tokens is not None:
usage['cache_read_input_tokens'] = cache_read_input_tokens
if output_tokens_details is not None:
usage['output_tokens_details'] = output_tokens_details
if server_tool_use is not None:
usage['server_tool_use'] = server_tool_use
if service_tier is not None:
usage['service_tier'] = service_tier
message_delta = {
'type': 'message_delta',
'delta': {
'stop_reason': stop_reason,
'stop_sequence': None,
},
'usage': {'output_tokens': output_tokens},
'usage': usage,
}
yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode()

View file

@ -488,8 +488,11 @@ async def get_current_user_by_api_key(request, api_key: str):
return user
VERIFIED_USER_ROLES = {'user', 'admin'}
def get_verified_user(user=Depends(get_current_user)):
if user.role not in {'user', 'admin'}:
if user.role not in VERIFIED_USER_ROLES:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@ -497,6 +500,19 @@ def get_verified_user(user=Depends(get_current_user)):
return user
async def get_verified_user_by_token(token: str, redis=None):
"""Resolve a verified user from a raw token, for WebSocket handshakes that run outside the HTTP dependency chain."""
decoded = decode_token(token)
if decoded is None or 'id' not in decoded or not await is_valid_token(decoded, redis):
return None
user = await Users.get_user_by_id(decoded['id'])
if user is None or user.role not in VERIFIED_USER_ROLES:
return None
return user
def get_admin_user(user=Depends(get_current_user)):
if user.role != 'admin':
raise HTTPException(

View file

@ -25,6 +25,7 @@ from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
from dateutil import parser as date_parser
from dateutil.rrule import rrulestr
from fastapi import Request
from fastapi.security import HTTPAuthorizationCredentials
@ -34,6 +35,7 @@ from open_webui.internal.db import get_async_db
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.users import Users
from open_webui.utils.auth import create_token
from open_webui.utils.misc import parse_duration
@ -72,30 +74,46 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
def _parse_rule(s: str, now: Optional[datetime] = None):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
SECONDLY/MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
"""
raw = s.replace('RRULE:', '')
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
lines = s.splitlines()
rule_count = sum(1 for line in lines if line.upper().startswith('RRULE:'))
if 'EXRULE' in s.upper():
raise ValueError('EXRULE is not supported in recurrence rules')
if rule_count > 1:
raise ValueError('only one RRULE is supported per recurrence rule')
rrule_line = next((line for line in lines if line.upper().startswith('RRULE:')), s)
raw = rrule_line.split(':', 1)[1] if rrule_line.upper().startswith('RRULE:') else rrule_line
parts = {k.upper(): v for k, v in (p.split('=', 1) for p in raw.split(';') if '=' in p)}
freq = parts.get('FREQ', '')
if freq in ('MINUTELY', 'HOURLY'):
if freq in ('SECONDLY', 'MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
if (
now is not None
and s.startswith('RRULE:')
and '\n' not in s
and '\r' not in s
and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'}
):
try:
interval = int(parts.get('INTERVAL', '1'))
if interval > 0:
step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval)
return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True)
except (TypeError, ValueError):
pass
return rrulestr(s, dtstart=epoch, ignoretz=True)
anchor = now or datetime.now()
rule = '\n'.join(line for line in lines if not line.upper().startswith('DTSTART')) or s
dtstart = next((line.rsplit(':', 1)[-1] for line in lines if line.upper().startswith('DTSTART')), None)
interval = int(parts.get('INTERVAL', '1'))
if interval < 1:
raise ValueError('RRULE INTERVAL must be a positive integer')
if freq == 'SECONDLY':
step = timedelta(seconds=interval)
elif freq == 'MINUTELY':
step = timedelta(minutes=interval)
else:
step = timedelta(hours=interval)
if dtstart:
start = date_parser.parse(dtstart, ignoretz=True)
emitted = ((anchor - start) // step) if anchor > start else 0
if 'BYMINUTE' in parts:
emitted *= len(parts['BYMINUTE'].split(','))
if 'BYSECOND' in parts:
emitted *= len(parts['BYSECOND'].split(','))
if emitted <= 100_000:
return rrulestr(s, ignoretz=True)
anchor = epoch + ((anchor - epoch) // step) * step
return rrulestr(rule, dtstart=anchor, ignoretz=True)
return rrulestr(s, ignoretz=True)
@ -430,7 +448,10 @@ async def execute_automation(app, automation: AutomationModel) -> None:
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
terminal_config = automation.data.get('terminal')
folder_id = automation.folder_id
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
await Automations.clear_folder_ids(automation.user_id, [folder_id])
folder_id = None
# Generate proper UUIDs for messages (same as frontend)
user_msg_id = str(uuid4())
@ -441,6 +462,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
chat_id,
automation.user_id,
ChatForm(
folder_id=folder_id,
chat={
'title': automation.name,
'models': [model_id],
@ -472,7 +494,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
{'role': 'user', 'content': prompt},
],
'meta': {'automation_id': automation.id},
}
},
),
)

View file

@ -11,7 +11,6 @@ from aiocache import cached
from fastapi import HTTPException, Request, status
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.functions import generate_function_chat_completion
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.routers.ollama import (
@ -30,7 +29,7 @@ from open_webui.socket.main import (
sio,
)
from open_webui.utils.filter import (
get_sorted_filter_ids,
get_filter_functions,
process_filter_functions,
)
from open_webui.utils.models import check_model_access, get_all_models
@ -362,11 +361,11 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
}
try:
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
result, _ = await process_filter_functions(
request=request,
filter_context=None,
filter_functions=filter_functions,
filter_type='outlet',
form_data=data,

View file

@ -0,0 +1,27 @@
from typing import Optional
TEMPORARY_CHAT_ID_PREFIX = 'temporary:'
LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:' # Legacy temporary chat prefix.
CHANNEL_CHAT_ID_PREFIX = 'channel:'
TEMPORARY_CHAT_ID_PREFIXES = (
TEMPORARY_CHAT_ID_PREFIX,
LEGACY_TEMPORARY_CHAT_ID_PREFIX,
)
NON_SAVED_CHAT_ID_PREFIXES = (*TEMPORARY_CHAT_ID_PREFIXES, CHANNEL_CHAT_ID_PREFIX)
def is_saved_chat_id(chat_id: Optional[str]) -> bool:
return bool(chat_id) and not chat_id.startswith(NON_SAVED_CHAT_ID_PREFIXES)
def is_temporary_chat_id(chat_id: Optional[str]) -> bool:
return bool(chat_id) and chat_id.startswith(TEMPORARY_CHAT_ID_PREFIXES)
def get_temporary_chat_session_id(chat_id: str) -> Optional[str]:
for prefix in TEMPORARY_CHAT_ID_PREFIXES:
if chat_id.startswith(prefix):
return chat_id.removeprefix(prefix)
return None

View file

@ -7,6 +7,7 @@ from typing import Any
from fastapi.responses import JSONResponse
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list
from open_webui.utils.task import (
get_task_model_id,
@ -51,16 +52,19 @@ async def compact_messages_for_request(
if not config['enable']:
return messages, None, False
system_messages = [messages[0]] if messages and messages[0].get('role') == 'system' else []
messages = messages[1:] if system_messages else messages
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata)
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
return messages, previous_summary, False
return [*system_messages, *messages], previous_summary, False
boundary = _find_compaction_boundary(messages, config['retention_percentage'])
compacted_messages = messages[:boundary]
recent_messages = messages[boundary:]
if not compacted_messages or not recent_messages:
return messages, previous_summary, False
return [*system_messages, *messages], previous_summary, False
event_emitter = None
if metadata.get('chat_id') and metadata.get('message_id'):
@ -110,7 +114,7 @@ async def compact_messages_for_request(
checkpoint_message_id = (
recent_messages[0].get('id') or metadata.get('user_message_id') or metadata.get('message_id')
)
if chat_id and checkpoint_message_id and not chat_id.startswith(('local:', 'channel:')):
if is_saved_chat_id(chat_id) and checkpoint_message_id:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
checkpoint_message_id,
@ -140,7 +144,7 @@ async def compact_messages_for_request(
}
)
return recent_messages, summary, True
return [*system_messages, *recent_messages], summary, True
async def compact_chat_branch(request, user, chat: Any, model_id: str, models: dict) -> dict:
@ -204,9 +208,7 @@ 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')
),
'retention_percentage': _clamp_retention_percentage(values.get('chat.context_compaction.retention_percentage')),
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
}
@ -260,9 +262,25 @@ async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict
for idx in range(len(messages) - 1, -1, -1):
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
input_tokens = (usage or {}).get('input_tokens') or (usage or {}).get('prompt_tokens')
if isinstance(usage, dict) and input_tokens:
tokens = int(input_tokens or 0) + int(usage.get('output_tokens') or usage.get('completion_tokens') or 0)
if isinstance(usage, dict) and (
tokens := (
int(
usage.get('prompt_tokens')
or usage.get('input_tokens')
or usage.get('prompt_eval_count')
or usage.get('prompt_n')
or 0
)
+ int(
usage.get('completion_tokens')
or usage.get('output_tokens')
or usage.get('eval_count')
or usage.get('predicted_n')
or 0
)
+ int(usage.get('cache_n') or 0)
)
):
tokens += _estimate_messages_tokens(messages[idx + 1 :])
return _build_context_usage(tokens, threshold)
@ -301,9 +319,26 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
for idx in range(len(messages) - 1, -1, -1):
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
if isinstance(usage, dict) and usage.get('input_tokens'):
total = int(usage.get('input_tokens') or 0) + int(usage.get('output_tokens') or 0)
return total + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
if isinstance(usage, dict) and (
tokens := (
int(
usage.get('prompt_tokens')
or usage.get('input_tokens')
or usage.get('prompt_eval_count')
or usage.get('prompt_n')
or 0
)
+ int(
usage.get('completion_tokens')
or usage.get('output_tokens')
or usage.get('eval_count')
or usage.get('predicted_n')
or 0
)
+ int(usage.get('cache_n') or 0)
)
):
return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages)
return estimated > threshold
@ -329,11 +364,21 @@ async def _generate_summary(
) -> str:
from open_webui.utils.chat import generate_chat_completion
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
models,
task_config = await Config.get_many(
'task.model.default',
'task.model.external',
'chat.context_compaction.model',
)
context_compaction_model = task_config.get('chat.context_compaction.model')
task_model_id = (
context_compaction_model
if context_compaction_model in models
else get_task_model_id(
model_id,
task_config.get('task.model.default'),
task_config.get('task.model.external'),
models,
)
)
if task_model_id not in models:
task_model_id = model_id

View file

@ -8,27 +8,61 @@ from open_webui.utils.plugin import get_function_module_from_cache
log = logging.getLogger(__name__)
async def get_function_module(request, function_id, load_from_db=True):
class FilterContext:
def __init__(self):
self.valves_by_id = None
self.function_valves = {}
self.user_valves = {}
async def get_function_valves(self, filter_ids, filter_id, Valves):
if filter_id not in self.function_valves:
if self.valves_by_id is None:
self.valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
valves = self.valves_by_id.get(filter_id)
self.function_valves[filter_id] = Valves(**(valves if valves else {}))
return self.function_valves[filter_id]
async def get_user_valves(self, filter_id, user_id, UserValves):
user_valves_key = (filter_id, user_id)
if user_valves_key not in self.user_valves:
self.user_valves[user_valves_key] = await get_user_valves(filter_id, user_id, UserValves)
return self.user_valves[user_valves_key]
async def get_user_valves(filter_id, user_id, UserValves):
user_valves_data = await Functions.get_user_valves_by_id_and_user_id(filter_id, user_id)
return UserValves(**(user_valves_data if user_valves_data else {}))
async def get_function_module(request, function_id, load_from_db=True, function=None):
"""
Get the function module by its ID.
"""
function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db=load_from_db)
function_module, _, _ = await get_function_module_from_cache(
request, function_id, function=function, load_from_db=load_from_db
)
return function_module
async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
if not ENABLE_PLUGINS:
return []
active_filters = await Functions.get_active_filter_ids()
def get_model_filter_ids(model, active_filters):
filter_ids = [fid for fid, is_global in active_filters if is_global]
if 'info' in model and 'meta' in model['info']:
if isinstance(model, dict) and '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 = {fid for fid, _ in active_filters}
return [fid for fid in filter_ids if fid in active_filter_ids]
async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list = None):
if not ENABLE_PLUGINS:
return [], []
active_filters = await Functions.get_active_filter_ids()
filter_ids = get_model_filter_ids(model, active_filters)
functions_by_id = {function.id: function for function in await Functions.get_functions_by_ids(filter_ids)}
async def get_active_status(filter_id):
function_module = await get_function_module(request, filter_id)
function_module = await get_function_module(request, filter_id, function=functions_by_id.get(filter_id))
if getattr(function_module, 'toggle', None):
return filter_id in (enabled_filter_ids or set())
@ -36,17 +70,12 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
return True
# Pre-compute active status for each filter (async functions can't be used in set comprehensions)
resolved_active = {}
for filter_id in active_filter_ids:
resolved_active[filter_id] = await get_active_status(filter_id)
active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active}
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids = [fid for fid in filter_ids if await get_active_status(fid)]
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
async def get_priority(function_id):
try:
function_module = await get_function_module(request, function_id)
function_module = await get_function_module(request, function_id, function=functions_by_id.get(function_id))
if function_module and hasattr(function_module, 'Valves'):
valves_db = valves_by_id.get(function_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
@ -61,12 +90,118 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
priorities[fid] = await get_priority(fid)
filter_ids.sort(key=lambda fid: (priorities.get(fid, 0), fid))
filter_functions = [functions_by_id[fid] for fid in filter_ids if fid in functions_by_id]
return filter_ids, filter_functions
async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
filter_ids, _ = await resolve_filter_pipeline(request, model, enabled_filter_ids)
return filter_ids
async def get_filter_functions(request, model: dict, enabled_filter_ids: list = None):
_, filter_functions = await resolve_filter_pipeline(request, model, enabled_filter_ids)
return filter_functions
async def apply_filter_valves(function_module, filter_context, valves_by_id, filter_ids, filter_id):
if not (hasattr(function_module, 'valves') and hasattr(function_module, 'Valves')):
return valves_by_id
if filter_context is not None:
function_module.valves = await filter_context.get_function_valves(filter_ids, filter_id, function_module.Valves)
return valves_by_id
if valves_by_id is None:
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
valves = valves_by_id.get(filter_id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
return valves_by_id
def get_filter_params(sig, filter_id, filter_type, form_data, extra_params):
params = {'event': form_data} if filter_type == 'stream' else {'body': form_data}
return params | {
k: v
for k, v in {
**extra_params,
'__id__': filter_id,
}.items()
if k in sig.parameters
}
async def apply_user_valves(function_module, filter_context, filter_id, params):
if '__user__' not in params or not hasattr(function_module, 'UserValves'):
return
user_id = params['__user__'].get('id')
if filter_context is not None:
user_valves = await filter_context.get_user_valves(filter_id, user_id, function_module.UserValves)
else:
user_valves = await get_user_valves(filter_id, user_id, function_module.UserValves)
params['__user__']['valves'] = user_valves
async def run_filter_handler(handler, params):
if inspect.iscoroutinefunction(handler):
return await handler(**params)
return handler(**params)
async def process_filter_function(
request,
function,
filter_type,
form_data,
extra_params,
filter_context,
valves_by_id,
filter_ids,
):
filter_id = function.id
function_module = await get_function_module(
request, filter_id, load_from_db=(filter_type != 'stream'), function=function
)
handler = getattr(function_module, filter_type, None)
if not handler:
return form_data, valves_by_id, None
skip_files = (
function_module.file_handler if filter_type == 'inlet' and hasattr(function_module, 'file_handler') else None
)
valves_by_id = await apply_filter_valves(function_module, filter_context, valves_by_id, filter_ids, filter_id)
try:
sig = inspect.signature(handler)
params = get_filter_params(sig, filter_id, filter_type, form_data, extra_params)
if '__user__' in sig.parameters:
try:
await apply_user_valves(function_module, filter_context, filter_id, params)
except Exception as e:
log.exception(f'Failed to get user values: {e}')
form_data = await run_filter_handler(handler, params)
except Exception as e:
log.debug(f'Error in {filter_type} handler {filter_id}: {e}')
raise e
return form_data, valves_by_id, skip_files
# 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):
async def process_filter_functions(
request,
filter_context,
filter_functions,
filter_type,
form_data,
extra_params,
):
if not ENABLE_PLUGINS:
return form_data, {}
@ -77,61 +212,18 @@ async def process_filter_functions(request, filter_functions, filter_type, form_
for function in filter_functions:
if not function:
continue
filter_id = function.id
function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream'))
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
continue
# Check if the function has a file_handler variable
if filter_type == 'inlet' and hasattr(function_module, 'file_handler'):
skip_files = function_module.file_handler
# Apply valves to the function
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
if valves_by_id is None:
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
valves = valves_by_id.get(filter_id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
try:
# Prepare parameters
sig = inspect.signature(handler)
params = {'body': form_data}
if filter_type == 'stream':
params = {'event': form_data}
params = params | {
k: v
for k, v in {
**extra_params,
'__id__': filter_id,
}.items()
if k in sig.parameters
}
# Handle user parameters
if '__user__' in sig.parameters:
if hasattr(function_module, 'UserValves'):
try:
params['__user__']['valves'] = function_module.UserValves(
**await Functions.get_user_valves_by_id_and_user_id(filter_id, params['__user__']['id'])
)
except Exception as e:
log.exception(f'Failed to get user values: {e}')
# Execute handler
if inspect.iscoroutinefunction(handler):
form_data = await handler(**params)
else:
form_data = handler(**params)
except Exception as e:
log.debug(f'Error in {filter_type} handler {filter_id}: {e}')
raise e
form_data, valves_by_id, file_handler = await process_filter_function(
request,
function,
filter_type,
form_data,
extra_params,
filter_context,
valves_by_id,
filter_ids,
)
skip_files = skip_files or file_handler
# Handle file cleanup for inlet
if skip_files:

View file

@ -13,9 +13,12 @@ from open_webui.env import (
FORWARD_USER_INFO_HEADER_USER_NAME,
FORWARD_USER_INFO_HEADER_USER_ROLE,
)
from open_webui.models.groups import Groups
log = logging.getLogger(__name__)
USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}')
def _mint_forward_user_jwt(user: Any) -> str:
now = int(time.time())
@ -59,7 +62,36 @@ def include_user_info_headers(headers: dict, user: Optional[Any] = None) -> dict
}
def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict:
def custom_headers_require_user_groups(custom_headers: Optional[dict]) -> bool:
if not custom_headers or not isinstance(custom_headers, dict):
return False
return any(
placeholder in str(value) for value in custom_headers.values() for placeholder in USER_GROUPS_PLACEHOLDERS
)
async def get_user_groups_for_custom_headers(
custom_headers: Optional[dict], user: Optional[Any] = None
) -> Optional[list]:
"""Fetch the user's groups only when a header value actually references a groups placeholder."""
if user is None or not custom_headers_require_user_groups(custom_headers):
return None
try:
return await Groups.get_groups_by_member_id(user.id)
except Exception:
log.exception('Failed to resolve user groups for custom headers')
return None
async def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict:
user_groups = await get_user_groups_for_custom_headers(custom_headers, user)
return parse_custom_headers(custom_headers, user, metadata, request=request, user_groups=user_groups)
def parse_custom_headers(
custom_headers: dict, user=None, metadata: dict = None, request=None, user_groups: Optional[list] = None
) -> dict:
if not custom_headers or not isinstance(custom_headers, dict):
return {}
@ -93,6 +125,8 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, r
'{{USER_NAME}}': (user.name.strip() if user else '') or '',
'{{USER_EMAIL}}': (user.email.strip() if user else '') or '',
'{{USER_ROLE}}': (user.role if user else '') or '',
'{{USER_GROUPS}}': ','.join(group.name.strip() for group in user_groups) if user_groups else '',
'{{USER_GROUP_IDS}}': ','.join(group.id for group in user_groups) if user_groups else '',
'{{USER_AGENT}}': user_agent,
}

View file

@ -0,0 +1,50 @@
"""The app-wide JSON codec, selected by the ``ENABLE_ORJSON`` env var.
Every module that would otherwise reach for stdlib ``json`` imports ``JSONCodec``
from here, so the whole app switches implementation from a single flag. With the
flag off these are stdlib ``json`` and engineio's codec verbatim, so the default
behaviour is exactly what it was before orjson entered the picture.
"""
from __future__ import annotations
import json as stdlib_json
from engineio import json as engineio_json
from open_webui.env import ENABLE_ORJSON
if ENABLE_ORJSON:
import orjson
class ORJSONCodec:
"""stdlib-``json``-compatible codec backed by orjson.
Anything orjson rejects (non-str dict keys, ints beyond 64 bits, ``NaN``
literals) falls back to engineio's stdlib-based codec, which keeps its
oversized-integer guard for untrusted client payloads.
"""
JSONDecodeError = engineio_json.JSONDecodeError
@staticmethod
def dumps(obj, *args, **kwargs):
try:
return orjson.dumps(obj).decode('utf-8')
except (TypeError, ValueError):
return engineio_json.dumps(obj, *args, **kwargs)
@staticmethod
def loads(s, *args, **kwargs):
try:
return orjson.loads(s)
except (TypeError, ValueError):
return engineio_json.loads(s, *args, **kwargs)
# Drop-in for stdlib ``json``: ``JSONCodec.dumps`` / ``JSONCodec.loads``.
JSONCodec = ORJSONCodec
# Codec handed to the socket.io/engineio managers, which default to their own.
SOCKETIO_JSON = ORJSONCodec
else:
JSONCodec = stdlib_json
SOCKETIO_JSON = engineio_json

View file

@ -0,0 +1,55 @@
"""orjson-backed JSON parsing/rendering for starlette/FastAPI requests and responses."""
from __future__ import annotations
import json
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
from open_webui.env import ENABLE_ORJSON
def apply_orjson_http_json() -> None:
"""Parse request bodies and serialize ``JSONResponse`` with orjson.
A no-op unless ``ENABLE_ORJSON`` is set, leaving starlette's own
stdlib-``json`` implementations untouched.
Not ``FastAPI(default_response_class=...)`` on purpose: an explicit
default disables FastAPI's Pydantic direct-to-bytes fast path for
``response_model`` routes. NaN/Infinity floats serialize as ``null``
instead of raising.
"""
if not ENABLE_ORJSON:
return
import orjson
def render(self, content: Any) -> bytes:
try:
return orjson.dumps(content)
except (TypeError, ValueError):
# Fallback matches starlette's JSONResponse.render exactly.
return json.dumps(
content,
ensure_ascii=False,
allow_nan=False,
indent=None,
separators=(',', ':'),
).encode('utf-8')
async def request_json(self) -> Any:
if not hasattr(self, '_json'):
body = await self.body()
try:
self._json = orjson.loads(body)
except (TypeError, ValueError):
# Fallback matches starlette's Request.json exactly, including the
# json.JSONDecodeError that FastAPI turns into a 422.
self._json = json.loads(body)
return self._json
JSONResponse.render = render
Request.json = request_json

View file

@ -17,6 +17,7 @@ from open_webui.env import (
ENABLE_OTEL_LOGS,
GLOBAL_LOG_LEVEL,
LOG_FORMAT,
LOGURU_DIAGNOSE,
)
if TYPE_CHECKING:
@ -178,6 +179,7 @@ def start_logger():
_json_sink,
level=GLOBAL_LOG_LEVEL,
filter=audit_filter,
diagnose=LOGURU_DIAGNOSE,
)
else:
logger.add(
@ -185,6 +187,7 @@ def start_logger():
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=audit_filter,
diagnose=LOGURU_DIAGNOSE,
)
if AUDIT_LOG_LEVEL != 'NONE' and ENABLE_AUDIT_LOGS_FILE:
try:
@ -195,6 +198,7 @@ def start_logger():
compression='zip',
format=file_format,
filter=lambda record: record['extra'].get('auditable') is True,
diagnose=LOGURU_DIAGNOSE,
)
except Exception as e:
logger.error(f'Failed to initialize audit log file handler: {str(e)}')

View file

@ -422,7 +422,7 @@ async def review_memory_after_turn(
if not features.get('memory'):
return
assistant_content = assistant_message.get('content', '')
assistant_content = get_content_from_message(assistant_message)
if not isinstance(assistant_content, str) or not assistant_content.strip():
return
@ -479,9 +479,9 @@ async def _review_memory(
for memory in (existing_memories or [])[:80]
]
assistant_content = assistant_message.get('content', '')
assistant_content = get_content_from_message(assistant_message)
if not isinstance(assistant_content, str):
assistant_content = get_content_from_message(assistant_message)
assistant_content = ''
transcript_lines = []
for message in messages[-16:]:

File diff suppressed because it is too large Load diff

View file

@ -194,9 +194,7 @@ def get_output_text(output: list | None) -> str:
continue
text = ''.join(
str(part.get('text'))
for part in parts
if isinstance(part, dict) and part.get('text') is not None
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)
@ -260,6 +258,7 @@ def convert_output_to_messages(
output: list,
raw: bool = False,
reasoning_format: str | None = None,
flatten_tool_images: bool = False,
) -> list[dict]:
"""
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
@ -278,6 +277,8 @@ def convert_output_to_messages(
(for Ollama, which expects reasoning as tagged content).
- ``'reasoning_content'``: set as ``reasoning_content`` top-level field
(for llama.cpp, which routes it via the chat template).
flatten_tool_images: Move tool output images into a following user
message for Chat Completions providers.
"""
if not output or not isinstance(output, list):
return []
@ -287,6 +288,10 @@ def convert_output_to_messages(
pending_content = []
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
pending_reasoning_details = []
pending_tool_image_urls = []
function_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id')
}
def flush_pending():
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
@ -311,8 +316,29 @@ def convert_output_to_messages(
pending_reasoning = []
pending_reasoning_details = []
def flush_tool_images():
nonlocal pending_tool_image_urls
if not pending_tool_image_urls:
return
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 pending_tool_image_urls],
],
}
)
pending_tool_image_urls = []
for item in output:
item_type = item.get('type', '')
if item_type != 'function_call_output':
flush_tool_images()
if item_type == 'message':
# Extract text from output_text content parts
@ -358,8 +384,17 @@ def convert_output_to_messages(
if url:
image_urls.append(url)
if image_urls:
# Multimodal tool content with image(s)
if flatten_tool_images:
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
if item.get('call_id') in function_call_ids:
pending_tool_image_urls.extend(image_urls)
elif image_urls:
messages.append(
{
'role': 'tool',
@ -431,6 +466,7 @@ def convert_output_to_messages(
pass
# Flush remaining content/tool_calls
flush_tool_images()
flush_pending()
return reconcile_tool_pairs(messages)
@ -627,9 +663,9 @@ def strip_empty_content_blocks(messages: list[dict]) -> list[dict]:
return messages
def openai_chat_message_template(model: str):
def openai_chat_message_template(model: str, message_id: str | None = None):
return {
'id': f'{model}-{str(uuid.uuid4())}',
'id': message_id if message_id else f'{model}-{str(uuid.uuid4())}',
'created': int(time.time()),
'model': model,
'choices': [{'index': 0, 'logprobs': None, 'finish_reason': None}],
@ -642,8 +678,9 @@ def openai_chat_chunk_message_template(
reasoning_content: str | None = None,
tool_calls: list[dict | None] = None,
usage: dict | None = None,
message_id: str | None = None,
) -> dict:
template = openai_chat_message_template(model)
template = openai_chat_message_template(model, message_id)
template['object'] = 'chat.completion.chunk'
template['choices'][0]['index'] = 0

View file

@ -0,0 +1,4 @@
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
if prefix_id and model_id.startswith(f'{prefix_id}.'):
return model_id[len(f'{prefix_id}.') :]
return model_id

View file

@ -428,7 +428,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
return models
async def check_model_access(user, model, db=None):
async def check_model_access(user, model, model_info=None, db=None):
if model.get('arena'):
meta = model.get('info', {}).get('meta', {})
access_grants = meta.get('access_grants', [])
@ -440,23 +440,35 @@ async def check_model_access(user, model, db=None):
):
raise Exception('Model not found')
else:
model_info = await Models.get_model_by_id(model.get('id'), db=db)
# Callers that already fetched the row (chat completion entry) pass it in
if model_info is None or model_info.id != model.get('id'):
model_info = await Models.get_model_by_id(model.get('id'), db=db)
if not model_info:
raise Exception('Model not found')
elif not (
# One group-membership fetch shared by the direct check and every
# base-model hop; skipped when no check below needs it.
user_group_ids = None
if user.id != model_info.user_id or model_info.base_model_id:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
if not (
user.id == model_info.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='model',
resource_id=model_info.id,
permission='read',
user_group_ids=user_group_ids,
db=db,
)
):
raise Exception('Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_role=user.role, db=db):
if not await has_base_model_access(
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids, db=db
):
raise Exception('Model not found')

View file

@ -15,6 +15,7 @@ from types import SimpleNamespace
from typing import Literal, Optional
import aiohttp
import jwt
from authlib.integrations.starlette_client import OAuth
from authlib.oauth2.rfc6749.errors import OAuth2Error
from authlib.oidc.core import UserInfo
@ -35,6 +36,7 @@ from mcp.shared.auth import (
from open_webui.config import (
DEFAULT_USER_ROLE,
ENABLE_OAUTH_GROUP_CREATION,
ENABLE_OAUTH,
ENABLE_OAUTH_GROUP_MANAGEMENT,
ENABLE_OAUTH_ROLE_MANAGEMENT,
ENABLE_OAUTH_SIGNUP,
@ -82,7 +84,7 @@ from open_webui.models.config import Config
from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
from open_webui.retrieval.web.utils import validate_url
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.utils.auth import create_token, get_password_hash
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.utils.misc import parse_duration
@ -120,6 +122,7 @@ OAUTH_RESOURCE_PARAMETER_MODES = {'auto', 'include', 'omit'}
OAUTH_RUNTIME_CONFIG = {
'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE),
'ENABLE_OAUTH': ('oauth.enable', ENABLE_OAUTH),
'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP),
'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': (
'oauth.refresh_token.include_scope',
@ -187,6 +190,7 @@ async def get_oauth_runtime_config() -> SimpleNamespace:
# Conservative default when the provider omits both expires_in and expires_at.
# Matches the value recommended by Authlib's compliance_fix documentation.
DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
NON_EXPIRING_TOKEN_EXPIRES_AT = 253402300799 # 9999-12-31 23:59:59 UTC
# Apereo CAS includes client_id in ID token JWS headers; Authlib 1.7/joserfc
@ -203,27 +207,43 @@ def _normalize_token_expiry(token: dict) -> dict:
Resolution order:
1. If *expires_at* is already present and non-None, trust it.
2. Else if *expires_in* is present and non-None, compute *expires_at*.
3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a
warning so operators can identify providers that omit expiration.
3. Else if a *refresh_token* is present, fall back to
``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a warning so operators can
identify providers that omit expiration.
4. Otherwise treat the token as non-expiring; there is no refresh path to
recover from a fabricated short expiry.
Also stamps *issued_at* for auditing.
"""
token['issued_at'] = datetime.now().timestamp()
if token.get('expires_at') is not None:
token['expires_at'] = int(token['expires_at'])
return token
expires_at = int(token['expires_at'])
elif token.get('expires_in') is not None:
expires_at = int(datetime.now().timestamp() + token['expires_in'])
elif token.get('refresh_token'):
log.warning(
"OAuth token response missing both 'expires_in' and 'expires_at'; "
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
)
expires_at = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
else:
log.info(
"OAuth token response missing 'expires_in', 'expires_at' and 'refresh_token'; treating token as non-expiring"
)
expires_at = NON_EXPIRING_TOKEN_EXPIRES_AT
if token.get('expires_in') is not None:
token['expires_at'] = int(datetime.now().timestamp() + token['expires_in'])
return token
id_token = token.get('id_token')
if id_token:
# Cap at the id_token expiry so pipes and tools never receive an expired JWT
try:
exp = jwt.decode(id_token, options={'verify_signature': False}).get('exp')
if exp is not None:
expires_at = min(expires_at, int(exp))
except Exception as e:
log.debug(f'Could not read exp from id_token: {e}')
# Neither field present — conservative fallback
log.warning(
"OAuth token response missing both 'expires_in' and 'expires_at'; "
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
)
token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
token['expires_at'] = expires_at
return token
@ -536,6 +556,20 @@ async def get_oauth_client_info_with_dynamic_client_registration(
log.error(f'Error parsing OAuth metadata from {url}: {e}')
continue
# Fail fast if authorization server metadata discovery did not resolve an
# authorization endpoint. Otherwise registration can still "succeed" (via
# the /register fallback below) while issuer/server_metadata stay unset,
# which later crashes at authorize time with authlib's
# RuntimeError: Missing "authorize_url" value. (#26647)
if oauth_server_metadata is None or not oauth_server_metadata.authorization_endpoint:
log.error(f'OAuth authorization server metadata discovery failed for {oauth_server_url}')
raise Exception(
'Could not discover the OAuth authorization server metadata '
f'(authorization_endpoint) for {oauth_server_url}. The MCP server must '
'expose RFC 8414 / RFC 9728 discovery documents so Open WebUI can '
'resolve where to send users to authorize.'
)
registration_url = None
if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
registration_url = str(oauth_server_metadata.registration_endpoint)
@ -802,6 +836,17 @@ class OAuthClientManager:
'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None),
}
# Defense-in-depth: when the server metadata is already known, pass the
# authorization/token endpoints explicitly so authlib does not rely solely
# on refetching server_metadata_url (which may be missing/unreachable) to
# resolve them. Prevents RuntimeError: Missing "authorize_url". (#26647)
server_metadata = oauth_client_info.server_metadata
if server_metadata is not None:
if getattr(server_metadata, 'authorization_endpoint', None):
kwargs['authorize_url'] = str(server_metadata.authorization_endpoint)
if getattr(server_metadata, 'token_endpoint', None):
kwargs['access_token_url'] = str(server_metadata.token_endpoint)
# Default to S256 for OAuth 2.1 (PKCE is mandatory per RFC 9700)
kwargs['code_challenge_method'] = 'S256'
@ -1138,7 +1183,22 @@ class OAuthClientManager:
redirect_uri_str = str(redirect_uri) if redirect_uri else None
# Pass explicit scope/resource parameters for providers that require them.
kwargs = build_oauth_request_params(client_info)
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
try:
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
except RuntimeError as e:
# authlib raises RuntimeError('Missing "authorize_url" value') when the
# authorization endpoint could not be resolved from server metadata.
# Surface a clear 400 instead of an uncaught 500 for clients that were
# registered before discovery was validated. (#26647)
log.error(f'OAuth authorize failed for client {client_id}: {e}')
raise HTTPException(
status_code=400,
detail=(
'OAuth authorization endpoint could not be resolved for this '
'client. Re-register the MCP server; its OAuth discovery '
'documents may be missing or unreachable.'
),
)
async def handle_callback(self, request, client_id: str, user_id: str, response):
client = await self.get_client(client_id)
@ -1641,8 +1701,8 @@ class OAuthManager:
get_kwargs['headers'] = {
'Authorization': f'Bearer {access_token}',
}
async with aiohttp.ClientSession(trust_env=True) as session:
# allow_redirects=False prevents redirect-based SSRF: validate_url() only vetted the initial URL (CVE-2026-45401 cohort).
# get_ssrf_safe_session pins the connect-time IP (defeats DNS rebinding); allow_redirects=False keeps validate_url's vet authoritative.
async with get_ssrf_safe_session() as session:
async with session.get(
picture_url,
**get_kwargs,
@ -1670,6 +1730,8 @@ class OAuthManager:
async def handle_login(self, request, provider):
auth_config = await get_oauth_runtime_config()
if not auth_config.ENABLE_OAUTH:
raise HTTPException(404)
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
@ -1690,6 +1752,8 @@ class OAuthManager:
async def handle_callback(self, request, provider, response, db=None):
auth_config = await get_oauth_runtime_config()
if not auth_config.ENABLE_OAUTH:
raise HTTPException(404)
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)

View file

@ -1,4 +1,3 @@
import copy
import json
from typing import Callable, Optional
@ -307,9 +306,10 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns:
dict: A modified payload compatible with the Ollama API.
"""
# Shallow copy metadata separately (may contain non-picklable objects)
# Only the top-level dict and the nested options dict are mutated below, so
# shallow copies suffice; deepcopy walked the entire message tree per call.
metadata = openai_payload.get('metadata')
openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != 'metadata'})
openai_payload = {k: v for k, v in openai_payload.items() if k != 'metadata'}
if metadata is not None:
openai_payload['metadata'] = dict(metadata)
ollama_payload = {}
@ -327,8 +327,9 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
# If there are advanced parameters in the payload, format them in Ollama's options field
if openai_payload.get('options'):
ollama_payload['options'] = openai_payload['options']
ollama_options = openai_payload['options']
# Copied before key deletions below so the caller's options stay intact
ollama_options = dict(openai_payload['options'])
ollama_payload['options'] = ollama_options
def parse_json(value: str) -> dict:
"""

View file

@ -23,6 +23,7 @@ from open_webui.env import (
REDIS_SENTINEL_PORT,
REDIS_SOCKET_CONNECT_TIMEOUT,
REDIS_SOCKET_KEEPALIVE,
REDIS_SOCKET_TIMEOUT,
REDIS_URL,
)
@ -257,6 +258,8 @@ def _socket_options() -> dict[str, Any]:
opts: dict[str, Any] = {}
if REDIS_SOCKET_CONNECT_TIMEOUT is not None:
opts['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT
if REDIS_SOCKET_TIMEOUT:
opts['socket_timeout'] = REDIS_SOCKET_TIMEOUT
if REDIS_SOCKET_KEEPALIVE:
opts['socket_keepalive'] = True
if REDIS_HEALTH_CHECK_INTERVAL:
@ -303,6 +306,7 @@ def get_redis_connection(
cache_key = (
redis_url,
tuple(redis_sentinels) if redis_sentinels else (),
redis_cluster,
async_mode,
decode_responses,
)

View file

@ -2,6 +2,7 @@ import json
from numbers import Number
from uuid import uuid4
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import (
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
@ -55,8 +56,6 @@ USAGE_TOKEN_KEYS = {
'input_tokens',
'output_tokens',
'total_tokens',
'prompt_tokens',
'completion_tokens',
}
USAGE_COST_KEYS = {
@ -68,6 +67,8 @@ USAGE_COST_KEYS = {
'completion_cost',
}
USAGE_SUMMABLE_KEYS = USAGE_TOKEN_KEYS | USAGE_COST_KEYS
USAGE_DETAIL_KEYS = {
'prompt_tokens_details',
'completion_tokens_details',
@ -105,7 +106,7 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict:
"""
Merge usage payloads from multiple model calls into one cumulative usage dict.
Token fields are additive; non-numeric metadata keeps the latest provider value.
Canonical token fields are additive; provider aliases keep the latest value.
"""
current_usage = normalize_usage(current or {}) if current else {}
incoming_usage = normalize_usage(incoming or {}) if incoming else {}
@ -117,7 +118,7 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict:
result = {**current_usage, **incoming_usage}
for key in USAGE_TOKEN_KEYS | USAGE_COST_KEYS:
for key in USAGE_SUMMABLE_KEYS:
if key in current_usage or key in incoming_usage:
current_value = current_usage.get(key, 0)
incoming_value = incoming_usage.get(key, 0)
@ -133,6 +134,17 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict:
incoming_usage.get(key) if isinstance(incoming_usage.get(key), dict) else {},
)
result['prompt_tokens'] = (
incoming_usage.get('prompt_tokens')
or incoming_usage.get('input_tokens')
or current_usage.get('prompt_tokens', 0)
)
result['completion_tokens'] = (
incoming_usage.get('completion_tokens')
or incoming_usage.get('output_tokens')
or current_usage.get('completion_tokens', 0)
)
return result
@ -226,12 +238,13 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
completion_id = f'chatcmpl-{str(uuid4())}'
first = True
async for data in ollama_streaming_response.body_iterator:
data = json.loads(data)
data = JSONCodec.loads(data)
model = data.get('model', 'ollama')
message_content = data.get('message', {}).get('content', None)
reasoning_content = data.get('message', {}).get('thinking', None)
tool_calls = data.get('message', {}).get('tool_calls', None)
message = data.get('message') or {}
message_content = message.get('content', None)
reasoning_content = message.get('thinking', None)
tool_calls = message.get('tool_calls', None)
openai_tool_calls = None
if tool_calls:
@ -244,8 +257,9 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if done:
usage = convert_ollama_usage_to_openai(data)
data = openai_chat_chunk_message_template(model, message_content, reasoning_content, openai_tool_calls, usage)
data['id'] = completion_id
data = openai_chat_chunk_message_template(
model, message_content, reasoning_content, openai_tool_calls, usage, message_id=completion_id
)
# First chunk must carry delta.role (OpenAI spec).
if first:
@ -255,7 +269,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if done and has_tool_calls:
data['choices'][0]['finish_reason'] = 'tool_calls'
line = f'data: {json.dumps(data)}\n\n'
line = f'data: {JSONCodec.dumps(data)}\n\n'
yield line
yield 'data: [DONE]\n\n'

View file

@ -28,6 +28,7 @@ from typing import Optional
import aiohttp
from open_webui.env import (
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_POOL_CONNECTIONS,
AIOHTTP_POOL_CONNECTIONS_PER_HOST,
@ -38,6 +39,16 @@ log = logging.getLogger(__name__)
_session: Optional[aiohttp.ClientSession] = None
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
_CLIENT_STREAM_TIMEOUT = aiohttp.ClientTimeout(
total=AIOHTTP_CLIENT_TIMEOUT,
sock_read=AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT,
)
def get_client_timeout(stream: bool = False) -> aiohttp.ClientTimeout:
return _CLIENT_STREAM_TIMEOUT if stream else _CLIENT_TIMEOUT
async def get_session() -> aiohttp.ClientSession:
"""Return the shared aiohttp ClientSession, creating it lazily."""
@ -56,7 +67,7 @@ async def get_session() -> aiohttp.ClientSession:
else:
connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited
connector = aiohttp.TCPConnector(**connector_kwargs)
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
timeout = get_client_timeout()
_session = aiohttp.ClientSession(
connector=connector,
timeout=timeout,
@ -104,14 +115,23 @@ async def cleanup_response(
await result
async def stream_wrapper(response, session=None, content_handler=None):
async def stream_wrapper(response, session=None, content_handler=None, passthrough=False):
"""Wrap a stream to ensure cleanup happens even if streaming is interrupted.
This is more reliable than BackgroundTask which may not run if the client
disconnects. When using the shared pool, ``session`` should be ``None``.
``passthrough=True`` yields raw network chunks (iter_any) instead of
lines: byte-identical output without a buffer scan, slice and copy per
line. Only for streams no internal consumer parses line-by-line.
"""
try:
stream = content_handler(response.content) if content_handler else response.content
if content_handler:
stream = content_handler(response.content)
elif passthrough:
stream = response.content.iter_any()
else:
stream = response.content
async for chunk in stream:
yield chunk
finally:

View file

@ -199,10 +199,14 @@ async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str]
return claimed
async def cancel_timers_for_chat(parent_chat_id: str, event: Literal['chat.read', 'chat.user_message']) -> None:
async def cancel_timers_for_chat(
parent_chat_id: str, event: Literal['chat.read', 'chat.user_message'], user_id: str
) -> None:
"""Owner-scoped: without the user filter, reading a chat cancels every user's timers on it."""
async with get_async_db() as db:
result = await db.execute(
select(Chat)
.where(Chat.user_id == user_id)
.where(Chat.meta['internal'].as_boolean().is_(True))
.where(Chat.meta['type'].as_string() == 'timer')
.where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id)

View file

@ -8,7 +8,7 @@ import json
import logging
import os
import re
from functools import partial, update_wrapper
from functools import cache, partial, update_wrapper
from typing import (
Any,
Awaitable,
@ -46,6 +46,7 @@ from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.tools.builtin import (
add_memory,
calculate_timestamp,
@ -61,14 +62,17 @@ from open_webui.tools.builtin import (
fetch_url,
generate_image,
get_current_timestamp,
grep_chat_files,
grep_knowledge_files,
kb_exec,
list_chat_files,
list_automations,
list_knowledge,
list_knowledge_bases,
list_memories,
list_memory_paths,
notify,
query_chat_files,
query_knowledge_bases,
query_knowledge_files,
read_memory_path,
@ -167,7 +171,7 @@ async def build_tool_server_headers(
# Interpolate template vars in custom connection headers
connection_headers = connection.get('headers', None)
if connection_headers and isinstance(connection_headers, dict):
headers.update(get_custom_headers(connection_headers, user, metadata))
headers.update(await get_custom_headers(connection_headers, user, metadata))
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
@ -183,13 +187,16 @@ async def build_tool_server_headers(
# Let no function be called without need, and let what
# it yields justify the cost of running it.
async def get_async_tool_function_and_apply_extra_params(
function: Callable, extra_params: dict
function: Callable, extra_params: dict, function_introspection=None
) -> Callable[..., Awaitable]:
sig = inspect.signature(function)
try:
type_hints = get_type_hints(function)
except Exception:
type_hints = {}
if function_introspection is None:
sig = inspect.signature(function)
try:
type_hints = get_type_hints(function)
except Exception:
type_hints = {}
else:
sig, type_hints = function_introspection
def coerce_kwargs(kwargs):
for name, value in kwargs.items():
@ -471,6 +478,45 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
return tools_dict
def get_attached_knowledge(model: dict, metadata: dict) -> list[dict]:
model_meta = model.get('info', {}).get('meta', {})
knowledge = []
seen = set()
for source, items in (
('model', model_meta.get('knowledge') or []),
('folder', metadata.get('folder_knowledge') or []),
):
for item in items:
if not isinstance(item, dict):
continue
key = (item.get('type'), item.get('id'))
if not all(key) or key in seen:
continue
knowledge.append({**item, 'source': source})
seen.add(key)
file_context_enabled = (model_meta.get('capabilities') or {}).get('file_context', True)
if not file_context_enabled:
for item in metadata.get('files') or []:
if not isinstance(item, dict) or item.get('type') not in ('collection', 'note'):
continue
key = (item.get('type'), item.get('id'))
if not all(key) or key in seen:
continue
knowledge.append(
{
'type': item.get('type'),
'id': item.get('id'),
'name': item.get('name'),
'source': 'chat',
}
)
seen.add(key)
return knowledge
async def get_builtin_tools(
request: Request, extra_params: dict, features: dict = None, model: dict = None
) -> dict[str, dict]:
@ -518,18 +564,42 @@ async def get_builtin_tools(
await Config.get('user.permissions'),
)
async def has_user_chat_permission(permission_key: str) -> bool:
if user.get('role') == 'admin':
return True
return await has_permission(
user.get('id', ''),
f'chat.{permission_key}',
await Config.get('user.permissions'),
)
# Time utilities - available for date calculations
if is_builtin_tool_enabled('time'):
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
metadata = extra_params.get('__metadata__') or {}
chat_files = metadata.get('files') or extra_params.get('__files__') or []
has_chat_files = any(
isinstance(item, dict)
and item.get('type', 'file') == 'file'
and (item.get('id') or item.get('url'))
and not str(item.get('id') or item.get('url')).startswith(('http://', 'https://', 'data:'))
for item in chat_files
)
if (
is_builtin_tool_enabled('files')
and get_model_capability('file_upload')
and not get_model_capability('file_context')
and has_chat_files
and await has_user_chat_permission('file_upload')
):
builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file])
# Knowledge base tools - conditional injection based on model knowledge
# If model has attached knowledge (any type), only provide query_knowledge_files
# Otherwise, provide all KB browsing tools
model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', [])
# Merge folder-attached knowledge so builtin tools can search it
folder_knowledge = extra_params.get('__metadata__', {}).get('folder_knowledge')
if folder_knowledge:
model_knowledge = list(model_knowledge or []) + list(folder_knowledge)
model_knowledge = get_attached_knowledge(model, metadata)
if is_builtin_tool_enabled('knowledge'):
from open_webui.env import ENABLE_KB_EXEC
@ -639,10 +709,9 @@ async def get_builtin_tools(
):
builtin_functions.append(execute_code)
metadata = extra_params.get('__metadata__') or {}
chat_id = metadata.get('chat_id') or ''
chat = None
if chat_id and not chat_id.startswith(('local:', 'channel:')):
if is_saved_chat_id(chat_id):
chat = await Chats.get_chat_by_id(chat_id)
# Notes tools - search, view, create, and update user's notes
@ -667,7 +736,8 @@ async def get_builtin_tools(
builtin_functions.append(view_skill)
# Task management - break down complex work into trackable steps
if is_builtin_tool_enabled('tasks'):
# Task state is stored on the chats row; local/channel IDs do not have one.
if is_builtin_tool_enabled('tasks') and is_saved_chat_id(chat_id):
builtin_functions.extend([create_tasks, update_task])
# Automation tools - create and manage scheduled automations from chat
@ -707,16 +777,15 @@ async def get_builtin_tools(
'__event_emitter__': extra_params.get('__event_emitter__'),
'__event_call__': extra_params.get('__event_call__'),
'__metadata__': extra_params.get('__metadata__'),
'__files__': chat_files,
'__chat_id__': extra_params.get('__chat_id__'),
'__message_id__': extra_params.get('__message_id__'),
'__model_knowledge__': model_knowledge,
},
get_builtin_function_introspection(func),
)
# Generate spec from function
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
spec = clean_openai_tool_schema(spec)
spec = get_builtin_tool_spec(func)
if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'):
parameters = spec.get('parameters', {})
parameters.get('properties', {}).pop('background', None)
@ -788,7 +857,7 @@ def parse_docstring(docstring):
return param_descriptions
def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
def convert_function_to_pydantic_model(func: Callable, function_introspection=None) -> type[BaseModel]:
"""
Converts a Python function's type hints and docstring to a Pydantic model,
including support for nested types, default values, and descriptions.
@ -800,8 +869,11 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
Returns:
A Pydantic model class.
"""
type_hints = get_type_hints(func)
signature = inspect.signature(func)
if function_introspection is None:
type_hints = get_type_hints(func)
signature = inspect.signature(func)
else:
signature, type_hints = function_introspection
parameters = signature.parameters
docstring = func.__doc__
@ -867,6 +939,26 @@ def clean_openai_tool_schema(spec: dict) -> dict:
return cleaned_spec
@cache
def get_builtin_function_introspection(func: Callable):
try:
type_hints = get_type_hints(func)
except Exception:
type_hints = {}
return inspect.signature(func), type_hints
@cache
def build_builtin_tool_spec(func: Callable) -> dict:
pydantic_model = convert_function_to_pydantic_model(func, get_builtin_function_introspection(func))
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
return clean_openai_tool_schema(spec)
def get_builtin_tool_spec(func: Callable) -> dict:
return copy.deepcopy(build_builtin_tool_spec(func))
def get_functions_from_tool(tool: object) -> list[Callable]:
return [
getattr(tool, func)
@ -913,23 +1005,22 @@ def resolve_schema(schema, components, resolved_schemas=None):
# Avoid infinite recursion on circular references
return {}
resolved_schemas.add(schema_name)
ref_parts = ref_path.strip('#/').split('/')
resolved = components
for part in ref_parts[1:]: # Skip the initial 'components'
resolved = resolved.get(part, {})
return resolve_schema(resolved, components, resolved_schemas)
# Per-path visited set so sibling refs to the same schema still resolve
return resolve_schema(resolved, components, resolved_schemas | {schema_name})
resolved_schema = copy.deepcopy(schema)
# Recursively resolve inner schemas
if 'properties' in resolved_schema:
for prop, prop_schema in resolved_schema['properties'].items():
resolved_schema['properties'][prop] = resolve_schema(prop_schema, components)
resolved_schema['properties'][prop] = resolve_schema(prop_schema, components, resolved_schemas)
if 'items' in resolved_schema:
resolved_schema['items'] = resolve_schema(resolved_schema['items'], components)
resolved_schema['items'] = resolve_schema(resolved_schema['items'], components, resolved_schemas)
# Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref
for keyword in ('oneOf', 'anyOf', 'allOf'):
@ -1234,6 +1325,8 @@ async def get_terminal_tools(
connection = next((c for c in connections if c.get('id') == terminal_id), None)
if connection is None:
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
if not connection.get('enabled', True):
raise RuntimeError(f"Terminal server '{terminal_id}' is disabled")
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
@ -1266,15 +1359,19 @@ async def get_terminal_tools(
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
# auth_type == "none": no Authorization header
system_prompt = server_data.get('system_prompt')
# Use chat_id as the per-session key for cwd tracking
metadata = extra_params.get('__metadata__', {})
session_id = metadata.get('chat_id')
if session_id:
headers['X-Session-Id'] = session_id
terminal_cwd = await get_terminal_cwd(server_data['url'], headers, cookies)
# Fetch live with the user's credentials so prompt changes apply without a restart
terminal_cwd, system_prompt = await asyncio.gather(
get_terminal_cwd(server_data['url'], headers, cookies),
get_terminal_system_prompt(server_data['url'], headers, cookies),
)
if not system_prompt:
system_prompt = server_data.get('system_prompt')
tools_dict = {}
for spec in specs:

View file

@ -34,8 +34,12 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc
# caller-controlled (user notification settings under
# ENABLE_USER_WEBHOOKS, automation notification triggers).
await asyncio.to_thread(validate_url, url)
payload = {}
except Exception as e:
log.warning('Webhook skipped, URL invalid or not publicly resolvable: %s', e)
return False
try:
payload = {}
# Slack and Google Chat Webhooks
if 'https://hooks.slack.com' in url or 'https://chat.googleapis.com' in url:
payload['text'] = _event_text(message, description, event_data)

View file

@ -2,13 +2,13 @@
# WIP: use this as a reference to build a minimal docker image
fastapi==0.136.3
uvicorn[standard]==0.41.0
uvicorn[standard]==0.51.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
python-jose==3.5.0
orjson==3.11.9
cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0

View file

@ -1,11 +1,11 @@
fastapi==0.136.3
uvicorn[standard]==0.41.0
uvicorn[standard]==0.51.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
python-jose==3.5.0
orjson==3.11.9
cryptography==48.0.0
bcrypt==5.0.0
argon2-cffi==25.1.0
@ -14,7 +14,9 @@ authlib==1.7.2
joserfc==1.7.4
requests==2.34.2
regex==2026.5.9 # supports a per-search timeout, which `re` does not
aiohttp==3.13.5 # do not update to 3.13.3 - broken
aiodns==4.0.4 # makes aiohttp resolve DNS on the event loop instead of the threadpool
async-timeout==5.0.1
aiocache==0.12.3
aiofiles==25.1.0
@ -78,6 +80,7 @@ unstructured==0.22.31
nltk==3.9.4
Markdown==3.10.2
beautifulsoup4==4.14.3
lxml==6.1.1
pypandoc==1.17
pandas==3.0.3
openpyxl==3.1.5

View file

@ -7,13 +7,13 @@ authors = [
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.136.3",
"uvicorn[standard]==0.41.0",
"uvicorn[standard]==0.51.0",
"pydantic==2.13.4",
"python-multipart==0.0.32",
"itsdangerous==2.2.0",
"python-socketio==5.16.2",
"python-jose==3.5.0",
"orjson==3.11.9",
"cryptography==48.0.0",
"bcrypt==5.0.0",
"argon2-cffi==25.1.0",
@ -23,6 +23,7 @@ dependencies = [
"requests==2.34.2",
"aiohttp==3.13.5", # do not update to 3.13.3 - broken
"aiodns==4.0.4", # makes aiohttp resolve DNS on the event loop instead of the threadpool
"async-timeout==5.0.1",
"aiocache==0.12.3",
"aiofiles==25.1.0",
@ -51,6 +52,7 @@ dependencies = [
"asgiref==3.11.1",
"tiktoken==0.13.0",
"regex==2026.5.9",
"mcp==1.27.2",
"openai==2.29.0",
@ -85,6 +87,7 @@ dependencies = [
"nltk==3.9.4",
"Markdown==3.10.2",
"beautifulsoup4==4.14.3",
"lxml==6.1.1",
"pypandoc==1.17",
"pandas==3.0.3",
"openpyxl==3.1.5",

View file

@ -410,6 +410,11 @@ input[type='number'] {
@apply line-clamp-1 absolute;
}
/* #676767 is 3.17:1 on the dark canvas; the light value already passes. */
html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before {
color: var(--color-gray-500);
}
.tiptap ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
@ -680,7 +685,7 @@ input[type='number'] {
}
.tiptap tr {
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
@apply dark:border-gray-850 text-xs;
}
.tippy-box[data-theme~='transparent'] {
@ -838,3 +843,78 @@ body {
#note-content-container .ProseMirror {
padding-bottom: 2rem; /* space for the bottom toolbar */
}
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
@layer utilities {
html.high-contrast:not(.dark) :is(input, textarea)::placeholder {
color: var(--color-gray-600);
}
html.high-contrast.dark :is(input, textarea)::placeholder {
color: var(--color-gray-500);
}
}
/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
:where() keeps these at low specificity so hover variants still win. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) .text-gray-400 {
color: var(--color-gray-600);
}
html:where(.high-contrast).dark .dark\:text-gray-600 {
color: var(--color-gray-400);
}
/* Elements that hover to a lighter grey than the new resting colour. */
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
}
}
/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
The utility stays in the layer so hover variants still outrank it; the classes below
are unlayered, so their override has to be too. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) .text-gray-500 {
color: var(--color-gray-600);
}
}
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
color: var(--color-gray-600);
}
/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) {
color: var(--color-gray-600);
}
html:where(.high-contrast).dark .dark\:text-gray-700 {
color: var(--color-gray-400);
}
/* Hover states that land lighter than the new resting colour. */
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
}
html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 {
color: var(--color-gray-800);
}
}
/* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */
html.high-contrast:not(.dark) .ai-autocompletion::after {
color: var(--color-gray-600);
}
/* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no
solid colour to raise; render it as flat text instead. */
html.high-contrast:not(.dark) .shimmer {
background: none;
color: var(--color-gray-700);
-webkit-text-fill-color: var(--color-gray-700);
}

View file

@ -14,6 +14,7 @@ export type AutomationData = {
export type AutomationForm = {
name: string;
folder_id?: string | null;
data: AutomationData;
meta?: {
system_prompt?: string;
@ -36,6 +37,7 @@ export type AutomationRunModel = {
export type AutomationResponse = {
id: string;
user_id: string;
folder_id: string | null;
name: string;
data: AutomationData;
meta: Record<string, any> | null;
@ -53,7 +55,8 @@ export const getAutomationItems = async (
token: string,
query: string | null,
status: string | null,
page: number
page: number,
folder_id?: string | null
): Promise<{ items: AutomationResponse[]; total: number }> => {
let error = null;
@ -67,6 +70,9 @@ export const getAutomationItems = async (
if (page) {
searchParams.append('page', page.toString());
}
if (folder_id !== undefined && folder_id !== null) {
searchParams.append('folder_id', folder_id);
}
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, {
method: 'GET',

View file

@ -885,6 +885,62 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => {
return res;
};
export const markChatUnreadById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/unread`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = 'detail' in err ? err.detail : err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const markChatsRead = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/read`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = 'detail' in err ? err.detail : err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const cloneChatById = async (token: string, id: string, title?: string) => {
let error = null;

View file

@ -235,6 +235,34 @@ export const deleteFolderById = async (token: string, id: string, deleteContents
return res;
};
export const markFolderChatsReadById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/read`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateFolderAccessById = async (token: string, id: string, accessGrants: any[]) => {
let error = null;

View file

@ -375,7 +375,9 @@ export const searchKnowledgeBases = async (
query: string | null = null,
viewOption: string | null = null,
page: number | null = null,
source: string | null = null
source: string | null = null,
orderBy: string | null = null,
direction: string | null = null
) => {
let error = null;
@ -384,6 +386,8 @@ export const searchKnowledgeBases = async (
if (viewOption) searchParams.append('view_option', viewOption);
if (source) searchParams.append('source', source);
if (page) searchParams.append('page', page.toString());
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, {
method: 'GET',

View file

@ -52,6 +52,7 @@ type YoutubeConfigForm = {
type RAGConfigForm = {
PDF_EXTRACT_IMAGES?: boolean;
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES?: string[];
ENABLE_GOOGLE_DRIVE_INTEGRATION?: boolean;
ENABLE_ONEDRIVE_INTEGRATION?: boolean;
EXTERNAL_DOCUMENT_LOADER_HEADERS?: Record<string, string>;

View file

@ -143,7 +143,8 @@ export const downloadFileBlob = async (
if (!res || !res.ok) return null;
const filename = path.split('/').pop() ?? 'file';
const blob = await res.blob();
const blob = await res.blob().catch(() => null);
if (!blob) return null;
return { blob, filename };
};
@ -170,7 +171,8 @@ export const archiveFromTerminal = async (
const disposition = res.headers.get('content-disposition') ?? '';
const match = disposition.match(/filename="?([^"]+)"?/);
const filename = match?.[1] ?? 'download.zip';
const blob = await res.blob();
const blob = await res.blob().catch(() => null);
if (!blob) return null;
return { blob, filename };
};

View file

@ -63,7 +63,7 @@
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const parsePassthroughParams = (value: string) =>
value
@ -597,6 +597,7 @@
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
<option value="litellm">{$i18n.t('LiteLLM')}</option>
</select>
</div>
</div>

View file

@ -62,7 +62,7 @@
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const stringifyJson = (value: object | null | undefined) => {
return JSON.stringify(value && Object.keys(value).length ? value : {}, null, 2);

View file

@ -72,7 +72,7 @@
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const registerOAuthClientHandler = async () => {
if (url === '') {

View file

@ -8,6 +8,9 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import FolderDropdown from '$lib/components/automations/FolderDropdown.svelte';
import { getFolders } from '$lib/apis/folders';
import { folders } from '$lib/stores';
import {
createAutomation,
@ -26,9 +29,11 @@
let name = '';
let prompt = '';
let model_id = '';
let folder_id = '';
let is_active = true;
let loading = false;
let foldersLoaded = false;
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@ -49,6 +54,7 @@
try {
const form: AutomationForm = {
name: name.trim(),
folder_id: folder_id || null,
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
@ -77,11 +83,17 @@
const init = async () => {
await tick();
if (!foldersLoaded && ($folders ?? []).length === 0) {
const res = await getFolders(localStorage.token).catch(() => null);
if (res) folders.set(res);
foldersLoaded = true;
}
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
folder_id = automation.folder_id ?? '';
is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
@ -90,6 +102,9 @@
name = cloneFrom.name;
prompt = cloneFrom.data.prompt;
model_id = cloneFrom.data.model_id;
folder_id = ($folders ?? []).some((folder) => folder.id === cloneFrom.folder_id)
? (cloneFrom.folder_id ?? '')
: '';
is_active = true;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(cloneFrom.data.rrule);
@ -98,6 +113,7 @@
name = '';
prompt = '';
model_id = '';
folder_id = '';
is_active = true;
}
};
@ -138,14 +154,18 @@
</div>
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 pb-3.5 pt-1 gap-2"
>
<div class="flex items-center gap-0.5 flex-wrap min-w-0 sm:flex-1">
<ScheduleDropdown bind:this={scheduleDropdown} side="top" align="start" />
<ModelDropdown bind:model_id side="top" align="start" />
<FolderDropdown bind:folder_id side="top" align="start" />
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="flex items-center justify-end gap-2 shrink-0">
<button
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"

View file

@ -468,7 +468,7 @@
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
class="dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
selectedModel = { id: model.model_id, name: model.name };
showModelModal = true;
@ -581,7 +581,7 @@
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<tr class="dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">

View file

@ -1,173 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getModelAnalytics } from '$lib/apis/analytics';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
let loading = true;
let orderBy = 'count';
let direction: 'asc' | 'desc' = 'desc';
const toggleSort = (key: string) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'name' ? 'asc' : 'desc';
}
};
const loadAnalytics = async () => {
loading = true;
try {
const result = await getModelAnalytics(localStorage.token);
const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
modelStats = (result?.models ?? []).map((entry) => ({
...entry,
name: modelsMap.get(entry.model_id) || entry.model_id
}));
} catch (err) {
console.error('Analytics load failed:', err);
}
loading = false;
};
$: sortedModels = [...modelStats].sort((a, b) => {
if (orderBy === 'name') {
return direction === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
return direction === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
onMount(loadAnalytics);
</script>
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
{$i18n.t('Model Usage')}
<span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !modelStats.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
{:else if modelStats.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Model')}
{#if orderBy === 'name'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if orderBy === 'count'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
>
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="font-normal text-gray-800 dark:text-gray-200">{model.name}</span>
</div>
</td>
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{model.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
{((model.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
<div class="text-right">
ⓘ {$i18n.t('Message counts are based on assistant responses.')}
</div>
</div>

View file

@ -1,160 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { getUserAnalytics } from '$lib/apis/analytics';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const i18n = getContext('i18n');
let userStats: Array<{ user_id: string; count: number }> = [];
let loading = true;
let orderBy = 'count';
let direction: 'asc' | 'desc' = 'desc';
const toggleSort = (key: string) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'user_id' ? 'asc' : 'desc';
}
};
const loadAnalytics = async () => {
loading = true;
try {
const result = await getUserAnalytics(localStorage.token, null, null, 100);
userStats = result?.users ?? [];
} catch (err) {
console.error('User analytics load failed:', err);
}
loading = false;
};
$: sortedUsers = [...userStats].sort((a, b) => {
if (orderBy === 'user_id') {
return direction === 'asc'
? a.user_id.localeCompare(b.user_id)
: b.user_id.localeCompare(a.user_id);
}
return direction === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalMessages = userStats.reduce((sum, u) => sum + u.count, 0);
onMount(loadAnalytics);
</script>
<div
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
{$i18n.t('User Activity')}
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !userStats.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
{:else if userStats.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleSort('user_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if orderBy === 'user_id'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if orderBy === 'count'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
>
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{idx + 1}
</td>
<td class="px-3 py-1.5">
<span class="font-normal text-gray-800 dark:text-gray-200 font-mono text-xs">
{user.user_id.substring(0, 8)}...
</span>
</td>
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{user.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
{((user.count / totalMessages) * 100).toFixed(1)}%
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
<div class="text-right">
ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')}
</div>
</div>

View file

@ -418,7 +418,7 @@
<tbody class="">
{#each items as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition rounded-xl"
class="dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition rounded-xl"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-normal">

Some files were not shown because too many files have changed in this diff Show more