mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-26 06:02:41 +00:00
Merge branch 'dev' into fix/oauth-token-exchange-audience
This commit is contained in:
commit
04a8a87910
293 changed files with 13828 additions and 11559 deletions
11
Dockerfile
11
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -5,6 +5,7 @@ Revises: c49178636c78
|
|||
Create Date: 2026-07-24 01:21:46.457057
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Revises: 9a1b2c3d4e5f
|
|||
Create Date: 2026-07-23 23:33:45.497453
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ API_CONFIG_FIELDS = (
|
|||
'auth_type',
|
||||
'headers',
|
||||
'azure',
|
||||
'api_type',
|
||||
'api_version',
|
||||
'extra_params',
|
||||
'passthrough_params',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
45
backend/open_webui/retrieval/web/openserp.py
Normal file
45
backend/open_webui/retrieval/web/openserp.py
Normal 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]
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
backend/open_webui/utils/chat_id.py
Normal file
27
backend/open_webui/utils/chat_id.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
50
backend/open_webui/utils/json_codec.py
Normal file
50
backend/open_webui/utils/json_codec.py
Normal 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
|
||||
55
backend/open_webui/utils/json_response.py
Normal file
55
backend/open_webui/utils/json_response.py
Normal 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
|
||||
|
|
@ -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)}')
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
4
backend/open_webui/utils/model_ids.py
Normal file
4
backend/open_webui/utils/model_ids.py
Normal 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
|
||||
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
82
src/app.css
82
src/app.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 === '') {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue