This commit is contained in:
Timothy Jaeryang Baek 2026-09-06 16:48:30 -04:00
parent 7fc979f95b
commit cb942bb94c
20 changed files with 503 additions and 182 deletions

View file

@ -18,3 +18,6 @@ uploads
**/*.db
_test
backend/data/*
.venv
.git

View file

@ -123,23 +123,30 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
# Make sure the user has access to the app and root directory
RUN chown -R $UID:$GID /app $HOME
# Install common system dependencies
# Slim cannot bundle a local model server or GPU runtime.
RUN if [ "$USE_SLIM" = "true" ] && { [ "$USE_CUDA" = "true" ] || [ "$USE_OLLAMA" = "true" ]; }; then \
echo "USE_SLIM cannot be combined with USE_CUDA or USE_OLLAMA" >&2; exit 1; fi
# Keep the slim runtime free of local document/audio processing tools.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc curl jq ca-certificates \
libmariadb-dev \
ffmpeg libsm6 libxext6 zstd \
&& rm -rf /var/lib/apt/lists/*
git curl jq ca-certificates zstd \
&& if [ "$USE_SLIM" != "true" ]; then \
apt-get install -y --no-install-recommends \
build-essential pandoc gcc libmariadb-dev ffmpeg libsm6 libxext6; \
fi && rm -rf /var/lib/apt/lists/*
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
COPY --chown=$UID:$GID ./backend/requirements*.txt ./
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
ENV UV_LINK_MODE=copy
RUN set -e; \
pip3 install --no-cache-dir uv; \
if [ "$USE_CUDA" = "true" ]; then \
if [ "$USE_SLIM" = "true" ]; then \
uv pip install --system -r requirements-slim.txt --no-cache-dir; \
elif [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \

View file

@ -17,6 +17,7 @@ from authlib.integrations.starlette_client import OAuth
from pydantic import BaseModel
from open_webui.env import (
USE_SLIM,
DATA_DIR,
DATABASE_URL,
ENABLE_ADMIN_CHAT_ACCESS,
@ -644,7 +645,7 @@ SSL_ASSERT_FINGERPRINT = os.getenv('SSL_ASSERT_FINGERPRINT', None)
ELASTICSEARCH_INDEX_PREFIX = os.getenv('ELASTICSEARCH_INDEX_PREFIX', 'open_webui_collections')
# Pgvector
PGVECTOR_DB_URL = os.getenv('PGVECTOR_DB_URL', DATABASE_URL)
if VECTOR_DB == 'pgvector' and not PGVECTOR_DB_URL.startswith('postgres'):
if not USE_SLIM and VECTOR_DB == 'pgvector' and not PGVECTOR_DB_URL.startswith('postgres'):
raise ValueError(
'Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database.'
)
@ -805,7 +806,7 @@ ORACLE_DB_POOL_MAX = int(os.getenv('ORACLE_DB_POOL_MAX', 10))
ORACLE_DB_POOL_INCREMENT = int(os.getenv('ORACLE_DB_POOL_INCREMENT', 1))
if VECTOR_DB == 'oracle23ai':
if not USE_SLIM and VECTOR_DB == 'oracle23ai':
if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN:
raise ValueError('Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN.')
if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD):

View file

@ -42,12 +42,13 @@ except ImportError:
print('dotenv not installed, skipping...')
DOCKER = os.getenv('DOCKER', 'False').lower() == 'true'
USE_SLIM = os.getenv('USE_SLIM_DOCKER', 'False').lower() == 'true'
USE_CUDA = os.getenv('USE_CUDA_DOCKER', 'false')
DEVICE_TYPE = 'cpu'
_cuda_error: Optional[str] = None
if USE_CUDA.lower() == 'true':
if not USE_SLIM and USE_CUDA.lower() == 'true':
try:
import torch # noqa: E402
@ -59,7 +60,7 @@ if USE_CUDA.lower() == 'true':
os.environ['USE_CUDA_DOCKER'] = 'false'
USE_CUDA = 'false'
if sys.platform == 'darwin' and DEVICE_TYPE == 'cpu':
if not USE_SLIM and sys.platform == 'darwin' and DEVICE_TYPE == 'cpu':
try:
import torch # noqa: E402
@ -844,7 +845,7 @@ MINERU_MAX_MARKDOWN_BYTES = (
# When enabled, skips pydub-based preprocessing (format conversion, compression,
# and chunked splitting) before sending files to processing engines. Useful when
# the upstream provider handles these steps or when ffmpeg is unavailable.
BYPASS_PYDUB_PREPROCESSING = os.getenv('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true'
BYPASS_PYDUB_PREPROCESSING = USE_SLIM or os.getenv('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true'
# When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path})
# is blocked. Enable only if you need direct passthrough to upstream OpenAI-

View file

@ -75,6 +75,7 @@ from open_webui.config import (
)
from open_webui.constants import ERROR_MESSAGES, TASKS
from open_webui.env import (
USE_SLIM,
AIOHTTP_CLIENT_SESSION_SSL,
AUDIT_EXCLUDED_PATHS,
AUDIT_INCLUDED_PATHS,
@ -2297,6 +2298,7 @@ async def get_app_config(request: Request):
'auto_redirect': config.get('oauth.auto_redirect'),
},
'features': {
'slim': USE_SLIM,
# --- Public: required by login/signup page pre-auth ---
'auth': WEBUI_AUTH,
'auth_trusted_header': bool(WEBUI_AUTH_TRUSTED_EMAIL_HEADER),

View file

@ -7,6 +7,7 @@ import zipfile
import ftfy
import requests
from fastapi import HTTPException
from azure.identity import DefaultAzureCredential
from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
@ -20,6 +21,7 @@ from langchain_core.documents import Document
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
GLOBAL_LOG_LEVEL,
USE_SLIM,
MINERU_MAX_MARKDOWN_BYTES,
REQUESTS_VERIFY,
)
@ -671,6 +673,19 @@ class Loader:
file_path=file_path,
)
else:
if USE_SLIM:
if file_ext == 'csv':
return CSVLoaderWithSummary(file_path, filename, self._detect_text_encoding(file_path))
if file_ext in ['htm', 'html']:
return BSHTMLLoader(file_path, open_encoding=self._detect_text_encoding(file_path))
if file_ext in ['txt', 'md', 'markdown', 'rst', 'xml'] or self._is_text_file(
file_ext, file_content_type
):
return TextLoader(file_path, encoding=self._detect_text_encoding(file_path))
raise HTTPException(
503,
'This file type requires an external document extractor in slim. Configure one that supports it.',
)
if file_ext == 'pdf':
loader = PyPDFLoader(
file_path,

View file

@ -10,8 +10,9 @@ from typing import Awaitable, Optional, Union
from urllib.parse import quote
import aiohttp
import numpy as np
import requests
from huggingface_hub import snapshot_download
from fastapi import HTTPException
from langchain_classic.retrievers import (
ContextualCompressionRetriever,
EnsembleRetriever,
@ -34,6 +35,7 @@ from open_webui.env import (
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS,
MPS_INFERENCE_LOCK,
OFFLINE_MODE,
USE_SLIM,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import Chats
@ -46,7 +48,7 @@ from open_webui.models.users import UserModel
from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.retrieval.external import retrieve_external_knowledge
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import get_vector_db_client
from open_webui.retrieval.vector.main import GetResult, SearchResult
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file
@ -333,7 +335,7 @@ class VectorSearchRetriever(BaseRetriever):
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None):
try:
log.debug('query_doc:doc %s', collection_name)
result = VECTOR_DB_CLIENT.search(
result = get_vector_db_client().search(
collection_name=collection_name,
vectors=[query_embedding],
limit=k,
@ -351,7 +353,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
def get_doc(collection_name: str, user: UserModel = None):
try:
log.debug('get_doc:doc %s', collection_name)
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
result = get_vector_db_client().get(collection_name=collection_name)
if result:
log.info('query_doc:result %s %s', result.ids, result.metadatas)
@ -1110,6 +1112,8 @@ def get_embedding_function(
if embedding_engine == '':
# Sentence transformers: CPU-bound sync operation
async def async_embedding_function(query, prefix=None, user=None):
if USE_SLIM:
raise HTTPException(503, 'Configure an external embedding engine (openai, ollama, azure_openai).')
# Deferred so a missing local model degrades RAG instead of crashing boot.
if embedding_function is None:
raise ValueError(
@ -1243,6 +1247,14 @@ async def generate_embeddings(
def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32):
if USE_SLIM and reranking_model and reranking_engine != 'external':
def unavailable(query, documents, user=None):
raise HTTPException(
503, 'Configure an external reranker, or clear the reranking model to use cosine scoring.'
)
return unavailable
if reranking_function is None:
return None
if reranking_engine == 'external':
@ -1688,6 +1700,8 @@ async def get_sources_from_items(
def get_model_path(model: str, update_model: bool = False):
from huggingface_hub import snapshot_download
# Construct huggingface_hub kwargs with local_files_only to return the snapshot path
cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME')
@ -1733,6 +1747,17 @@ from langchain_core.callbacks import Callbacks
from langchain_core.documents import BaseDocumentCompressor, Document
def cosine_similarity(query, documents) -> np.ndarray:
"""Score one query against documents without loading a model runtime."""
if len(documents) == 0:
return np.array([], dtype=float)
query = np.asarray(query, dtype=float).reshape(-1)
documents = np.asarray(documents, dtype=float)
query = query / max(np.linalg.norm(query), 1e-12)
documents = documents / np.maximum(np.linalg.norm(documents, axis=1, keepdims=True), 1e-12)
return documents @ query
class RerankCompressor(BaseDocumentCompressor):
embedding_function: Any
top_n: int
@ -1768,18 +1793,18 @@ class RerankCompressor(BaseDocumentCompressor):
query: str,
callbacks: Callbacks | None = None,
) -> Sequence[Document]:
if not documents:
return []
reranking = self.reranking_function is not None
scores = None
if reranking:
scores = await asyncio.to_thread(self.reranking_function, query, documents)
else:
from sentence_transformers import util as st_util
query_embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
doc_texts = [doc.page_content for doc in documents]
document_embedding = await self.embedding_function(doc_texts, RAG_EMBEDDING_CONTENT_PREFIX)
scores = st_util.cos_sim(query_embedding, document_embedding)[0]
scores = cosine_similarity(query_embedding, document_embedding)
if scores is not None:
docs_with_scores = list(

View file

@ -15,9 +15,8 @@ transparently dispatches each call to a worker thread via
`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)`
in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive.
The original `VECTOR_DB_CLIENT` is unchanged, so callers already running
inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not
affected.
Client initialization and calls run in the worker thread. Synchronous callers
already inside `run_in_threadpool` use `get_vector_db_client()` directly.
Thread-safety expectations
--------------------------
@ -55,7 +54,7 @@ from __future__ import annotations
import asyncio
from typing import Dict, List, Optional, Union
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import get_vector_db_client
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@ -73,30 +72,30 @@ class AsyncVectorDBClient:
typically swallowed by surrounding ``try/except``).
"""
def __init__(self, sync_client: VectorDBBase) -> None:
def __init__(self, sync_client: Optional[VectorDBBase] = None) -> None:
self._sync = sync_client
@property
def sync(self) -> VectorDBBase:
"""Escape hatch for code that must call the sync client directly
(e.g. already inside a worker thread)."""
return self._sync
return self._sync if self._sync is not None else get_vector_db_client()
@property
def supports_hybrid_search(self) -> bool:
return type(self._sync).hybrid_search is not VectorDBBase.hybrid_search
return type(self.sync).hybrid_search is not VectorDBBase.hybrid_search
async def has_collection(self, collection_name: str) -> bool:
return await asyncio.to_thread(self._sync.has_collection, collection_name)
return await asyncio.to_thread(lambda: self.sync.has_collection(collection_name))
async def delete_collection(self, collection_name: str) -> None:
return await asyncio.to_thread(self._sync.delete_collection, collection_name)
return await asyncio.to_thread(lambda: self.sync.delete_collection(collection_name))
async def insert(self, collection_name: str, items: List[VectorItem]) -> None:
return await asyncio.to_thread(self._sync.insert, collection_name, items)
return await asyncio.to_thread(lambda: self.sync.insert(collection_name, items))
async def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
return await asyncio.to_thread(self._sync.upsert, collection_name, items)
return await asyncio.to_thread(lambda: self.sync.upsert(collection_name, items))
async def search(
self,
@ -105,7 +104,7 @@ class AsyncVectorDBClient:
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit)
return await asyncio.to_thread(lambda: self.sync.search(collection_name, vectors, filter, limit))
async def hybrid_search(
self,
@ -117,13 +116,7 @@ class AsyncVectorDBClient:
hybrid_bm25_weight: float = 0.5,
) -> Optional[SearchResult]:
return await asyncio.to_thread(
self._sync.hybrid_search,
collection_name,
query,
vectors,
filter,
limit,
hybrid_bm25_weight,
lambda: self.sync.hybrid_search(collection_name, query, vectors, filter, limit, hybrid_bm25_weight)
)
async def query(
@ -132,10 +125,10 @@ class AsyncVectorDBClient:
filter: Dict,
limit: Optional[int] = None,
) -> Optional[GetResult]:
return await asyncio.to_thread(self._sync.query, collection_name, filter, limit)
return await asyncio.to_thread(lambda: self.sync.query(collection_name, filter, limit))
async def get(self, collection_name: str) -> Optional[GetResult]:
return await asyncio.to_thread(self._sync.get, collection_name)
return await asyncio.to_thread(lambda: self.sync.get(collection_name))
async def delete(
self,
@ -143,10 +136,10 @@ class AsyncVectorDBClient:
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter)
return await asyncio.to_thread(lambda: self.sync.delete(collection_name, ids, filter))
async def reset(self) -> None:
return await asyncio.to_thread(self._sync.reset)
return await asyncio.to_thread(lambda: self.sync.reset())
ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT)
ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient()

View file

@ -16,6 +16,8 @@ from open_webui.config import (
CHROMA_HTTP_SSL,
CHROMA_TENANT,
)
from open_webui.env import USE_SLIM
from fastapi import HTTPException
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@ -29,6 +31,8 @@ log = logging.getLogger(__name__)
class ChromaClient(VectorDBBase):
def __init__(self):
if USE_SLIM and not CHROMA_HTTP_HOST:
raise HTTPException(503, 'Configure CHROMA_HTTP_HOST: embedded Chroma is unavailable in slim.')
settings_dict = {
'allow_reset': True,
'anonymized_telemetry': False,
@ -58,7 +62,7 @@ class ChromaClient(VectorDBBase):
def has_collection(self, collection_name: str) -> bool:
try:
self.client.get_collection(name=collection_name)
self.client.get_collection(name=collection_name, embedding_function=None)
return True
except NotFoundError:
return False
@ -76,7 +80,7 @@ class ChromaClient(VectorDBBase):
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
try:
collection = self.client.get_collection(name=collection_name)
collection = self.client.get_collection(name=collection_name, embedding_function=None)
if collection:
result = collection.query(
query_embeddings=vectors,
@ -105,7 +109,7 @@ class ChromaClient(VectorDBBase):
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]:
# Query the items from the collection based on the filter.
try:
collection = self.client.get_collection(name=collection_name)
collection = self.client.get_collection(name=collection_name, embedding_function=None)
if collection:
result = collection.get(
where=filter,
@ -125,7 +129,7 @@ class ChromaClient(VectorDBBase):
def get(self, collection_name: str) -> Optional[GetResult]:
# Get all the items in the collection.
collection = self.client.get_collection(name=collection_name)
collection = self.client.get_collection(name=collection_name, embedding_function=None)
if collection:
result = collection.get()
return GetResult(
@ -139,7 +143,9 @@ class ChromaClient(VectorDBBase):
def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created.
collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'})
collection = self.client.get_or_create_collection(
name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None
)
ids = [item['id'] for item in items]
documents = [item['text'] for item in items]
@ -157,7 +163,9 @@ class ChromaClient(VectorDBBase):
def upsert(self, collection_name: str, items: list[VectorItem]):
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'})
collection = self.client.get_or_create_collection(
name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None
)
ids = [item['id'] for item in items]
documents = [item['text'] for item in items]
@ -174,7 +182,7 @@ class ChromaClient(VectorDBBase):
):
# Delete the items from the collection based on the ids.
try:
collection = self.client.get_collection(name=collection_name)
collection = self.client.get_collection(name=collection_name, embedding_function=None)
if collection:
if ids:
collection.delete(ids=ids)

View file

@ -1,8 +1,12 @@
from threading import Lock
from fastapi import HTTPException
from open_webui.config import (
ENABLE_MILVUS_MULTITENANCY_MODE,
ENABLE_QDRANT_MULTITENANCY_MODE,
VECTOR_DB,
)
from open_webui.env import USE_SLIM
from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType
@ -88,4 +92,35 @@ class Vector:
raise ValueError(f'Unsupported vector type: {vector_type}')
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
VECTOR_DB_CLIENT = None if USE_SLIM else Vector.get_vector(VECTOR_DB)
_vector_client_lock = Lock()
def get_vector_db_client() -> VectorDBBase:
"""Initialize slim's remote client on first use so chat can start without it."""
global VECTOR_DB_CLIENT
if VECTOR_DB_CLIENT is not None:
return VECTOR_DB_CLIENT
with _vector_client_lock:
if VECTOR_DB_CLIENT is None:
from open_webui import config
if VECTOR_DB == VectorType.CHROMA and not config.CHROMA_HTTP_HOST:
raise HTTPException(
503, 'Slim requires remote vector storage. Set CHROMA_HTTP_HOST or configure another VECTOR_DB.'
)
if VECTOR_DB == VectorType.MILVUS and not config.MILVUS_URI.startswith(('http://', 'https://', 'tcp://')):
raise HTTPException(503, 'Slim requires an external MILVUS_URI, not a local database file.')
if VECTOR_DB == VectorType.QDRANT and not config.QDRANT_URI:
raise HTTPException(503, 'Configure QDRANT_URI for remote vector storage.')
if VECTOR_DB == VectorType.PGVECTOR and not config.PGVECTOR_DB_URL.startswith('postgres'):
raise HTTPException(503, 'Configure PGVECTOR_DB_URL for remote vector storage.')
try:
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
503, f'Unable to connect to configured vector database ({VECTOR_DB}): {exc}'
) from exc
return VECTOR_DB_CLIENT

View file

@ -29,7 +29,9 @@ import urllib3.connection
import urllib3.connectionpool
import validators
from requests.adapters import HTTPAdapter
from fastapi import HTTPException
from fastapi.concurrency import run_in_threadpool
from bs4 import BeautifulSoup
from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
@ -58,6 +60,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SSL_CERT_FILE,
AIOHTTP_CLIENT_TIMEOUT,
USER_AGENT,
USE_SLIM,
)
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
from open_webui.retrieval.loaders.microsoft_web_iq import MicrosoftWebIQLoader
@ -643,6 +646,26 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
raise e
class TextHtmlEvaluator:
"""Extract rendered text without Unstructured or its local NLP dependencies."""
def __init__(self, remove_selectors=None):
self.remove_selectors = remove_selectors or []
def text(self, html):
soup = BeautifulSoup(html, 'lxml')
for selector in ['script', 'style', 'noscript', *self.remove_selectors]:
for element in soup.select(selector):
element.decompose()
return soup.get_text(separator='\n', strip=True)
def evaluate(self, page, browser, response):
return self.text(page.content())
async def evaluate_async(self, page, browser, response):
return self.text(await page.content())
class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin):
"""Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection.
@ -674,6 +697,8 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
playwright_timeout: Optional[int] = 10000,
):
"""Initialize with additional safety parameters and remote browser support."""
if USE_SLIM and not playwright_ws_url:
raise HTTPException(503, 'Configure PLAYWRIGHT_WS_URL. Slim requires a remote browser.')
proxy_server = proxy.get('server') if proxy else None
if trust_env and not proxy_server:
@ -690,7 +715,8 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
urls=web_paths,
continue_on_failure=continue_on_failure,
headless=headless if playwright_ws_url is None else False,
remove_selectors=remove_selectors,
remove_selectors=None if USE_SLIM else remove_selectors,
evaluator=TextHtmlEvaluator(remove_selectors) if USE_SLIM else None,
proxy=proxy,
)
self.verify_ssl = verify_ssl

View file

@ -46,6 +46,7 @@ from open_webui.env import (
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
ENV,
USE_SLIM,
)
from open_webui.events import EVENTS, publish_event
from open_webui.models.config import Config
@ -58,9 +59,10 @@ from open_webui.utils.session_pool import get_session
from pydantic import BaseModel
# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13
from pydub import AudioSegment
from pydub.silence import split_on_silence
from pydub.utils import mediainfo
if not USE_SLIM:
from pydub import AudioSegment
from pydub.silence import split_on_silence
from pydub.utils import mediainfo
log = logging.getLogger(__name__)
router = APIRouter()
@ -213,6 +215,8 @@ def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_p
def set_faster_whisper_model(model: str, auto_update: bool = False):
if USE_SLIM:
raise HTTPException(503, 'Configure an external speech-to-text engine. Local Whisper is unavailable in slim.')
whisper_model = None
if model:
from faster_whisper import WhisperModel
@ -285,6 +289,12 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
@router.post('/config/update')
async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)):
if USE_SLIM:
current = await Config.get_many('audio.stt.engine', 'audio.tts.engine')
if form_data.stt.ENGINE == '' and current.get('audio.stt.engine') != '':
raise HTTPException(400, 'Local Whisper is unavailable in slim. Select an external speech-to-text engine.')
if form_data.tts.ENGINE == 'transformers' and current.get('audio.tts.engine') != 'transformers':
raise HTTPException(400, 'Local TTS is unavailable in slim. Select an external text-to-speech engine.')
await Config.upsert(
{
**config_updates(form_data.tts.model_dump(), TTS_CONFIG_KEYS),
@ -292,7 +302,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
}
)
if form_data.stt.ENGINE == '':
if form_data.stt.ENGINE == '' and not USE_SLIM:
request.app.state.faster_whisper_model = await asyncio.to_thread(
set_faster_whisper_model, form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
)
@ -314,6 +324,8 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
def load_speech_pipeline(request):
if USE_SLIM:
raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.')
from datasets import load_dataset
from transformers import pipeline
@ -328,7 +340,9 @@ def load_speech_pipeline(request):
async def _raise_tts_error(exc: Exception, r=None) -> None:
"""Raise a standardised HTTPException from a TTS provider failure."""
code = r.status if r is not None else 500
if isinstance(exc, HTTPException):
raise exc
code = r.status if r is not None and r.status >= 400 else 500
# LICENSE covers this Open WebUI error identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
@ -351,8 +365,29 @@ async def _write_tts_cache(
audio: bytes,
body_path: Path,
payload: dict,
content_type: str = 'audio/mpeg',
) -> None:
"""Persist audio + request metadata to the speech cache."""
if USE_SLIM:
mime_type = content_type.split(';')[0].strip().lower()
if mime_type not in {
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/ogg',
'audio/opus',
'audio/webm',
'audio/flac',
'audio/aac',
'audio/mp4',
}:
raise HTTPException(
502,
f'TTS returned unsupported format {mime_type}. Configure the provider to return MP3, WAV, Ogg, or another browser-playable audio format.',
)
async with aiofiles.open(file_path.with_suffix('.mime'), 'w') as f:
await f.write(content_type)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio)
async with aiofiles.open(body_path, 'w') as f:
@ -389,6 +424,10 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
audio_data = await r.read()
content_type = r.headers.get('Content-Type', 'audio/mpeg')
if USE_SLIM:
await _write_tts_cache(file_path, audio_data, file_body_path, payload, content_type)
return FileResponse(file_path, media_type=content_type)
if not await asyncio.to_thread(transcode_audio_to_mp3, audio_data, content_type, file_path):
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_data)
@ -430,8 +469,9 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
await _write_tts_cache(file_path, await r.read(), file_body_path, payload)
return FileResponse(file_path)
content_type = r.headers.get('Content-Type', 'audio/mpeg')
await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type)
return FileResponse(file_path, media_type=content_type if USE_SLIM else None)
except Exception as exc:
log.exception(exc)
await _raise_tts_error(exc, r)
@ -465,8 +505,9 @@ async def _tts_azure(request, payload, file_path, file_body_path, user):
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
await _write_tts_cache(file_path, await r.read(), file_body_path, payload)
return FileResponse(file_path)
content_type = r.headers.get('Content-Type', 'audio/mpeg')
await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type)
return FileResponse(file_path, media_type=content_type if USE_SLIM else None)
except Exception as exc:
log.exception(exc)
await _raise_tts_error(exc, r)
@ -474,6 +515,8 @@ async def _tts_azure(request, payload, file_path, file_body_path, user):
async def _tts_transformers(request, payload, file_path, file_body_path, user):
"""Generate speech via the local HuggingFace SpeechT5 pipeline (thread-offloaded)."""
if USE_SLIM:
raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.')
import soundfile as sf
import torch
@ -558,6 +601,8 @@ _TTS_ENGINES = {
@router.post('/speech')
async def speech(request: Request, user=Depends(get_verified_user)):
engine = await Config.get('audio.tts.engine')
if USE_SLIM and engine in ('', 'transformers'):
raise HTTPException(503, 'Configure an external text-to-speech engine.')
if engine == '':
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -572,7 +617,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(
body + str(engine).encode('utf-8') + str(await Config.get('audio.tts.model')).encode('utf-8')
body
+ str(engine).encode('utf-8')
+ str(await Config.get('audio.tts.model')).encode('utf-8')
+ (b':slim' if USE_SLIM else b'')
).hexdigest()
file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3')
@ -587,7 +635,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
subject_id=name,
data={'engine': engine, 'cached': True},
)
return FileResponse(file_path)
content_type = None
if USE_SLIM:
async with aiofiles.open(file_path.with_suffix('.mime')) as f:
content_type = await f.read()
return FileResponse(file_path, media_type=content_type)
try:
payload = JSONCodec.loads(body)
@ -960,7 +1012,11 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
session = await get_session()
if use_chat_completions:
audio_file_to_use = file_path
if is_audio_conversion_required(file_path):
if USE_SLIM and Path(filename).suffix.lower() not in ('.mp3', '.wav'):
raise HTTPException(
400, 'Mistral chat transcription requires MP3 or WAV in slim; local conversion is unavailable.'
)
if not BYPASS_PYDUB_PREPROCESSING and is_audio_conversion_required(file_path):
log.debug('Converting audio to mp3 for chat completions API')
converted_path = await asyncio.to_thread(convert_audio_to_mp3, file_path)
if converted_path:

View file

@ -6,6 +6,8 @@ from fastapi.concurrency import run_in_threadpool
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import MPS_INFERENCE_LOCK
from open_webui.events import EVENTS, publish_event
from open_webui.env import USE_SLIM
from open_webui.retrieval.utils import cosine_similarity
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.feedbacks import (
@ -69,6 +71,8 @@ _embedding_model = None
def _get_embedding_model():
global _embedding_model
if USE_SLIM:
return None
if _embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
@ -217,6 +221,7 @@ class LeaderboardResponse(BaseModel):
@router.get('/leaderboard', response_model=LeaderboardResponse)
async def get_leaderboard(
request: Request,
query: Optional[str] = None,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
@ -226,7 +231,17 @@ async def get_leaderboard(
similarities = None
if query and query.strip():
similarities = await run_in_threadpool(_compute_similarities, feedbacks, query.strip())
if USE_SLIM:
tags = list({tag for feedback in feedbacks for tag in (feedback.data or {}).get('tags', [])})
embeddings = await request.app.state.EMBEDDING_FUNCTION([query.strip(), *tags], user=user)
scores = cosine_similarity(embeddings[0], embeddings[1:])
tag_scores = dict(zip(tags, scores.tolist()))
similarities = {
feedback.id: max((tag_scores.get(tag, 0) for tag in (feedback.data or {}).get('tags', [])), default=0)
for feedback in feedbacks
}
else:
similarities = await run_in_threadpool(_compute_similarities, feedbacks, query.strip())
elo_stats = _calculate_elo(feedbacks, similarities)
tags_by_model = _get_top_tags(feedbacks)

View file

@ -58,6 +58,7 @@ from open_webui.env import (
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION,
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
USE_SLIM,
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_db, get_async_session
@ -82,7 +83,7 @@ from open_webui.retrieval.utils import (
query_doc_with_hybrid_search,
)
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import get_vector_db_client
from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.retrieval.web.azure import search_azure
from open_webui.retrieval.web.bing import search_bing
@ -150,7 +151,7 @@ def get_ef(
auto_update: bool = RAG_EMBEDDING_MODEL_AUTO_UPDATE,
):
ef = None
if embedding_model and engine == '':
if embedding_model and engine == '' and not USE_SLIM:
from sentence_transformers import SentenceTransformer
try:
@ -178,6 +179,17 @@ def get_rf(
rf = None
# Convert timeout string to int or None (system default)
timeout_value = int(external_reranker_timeout) if external_reranker_timeout else None
if reranking_model and engine == 'external':
from open_webui.retrieval.models.external import ExternalReranker
return ExternalReranker(
url=external_reranker_url,
api_key=external_reranker_api_key,
model=reranking_model,
timeout=timeout_value,
)
if USE_SLIM:
return None
if reranking_model:
if any(model in reranking_model for model in ['jinaai/jina-colbert-v2']):
try:
@ -192,55 +204,39 @@ def get_rf(
log.error(f'ColBERT: {e}')
raise Exception(ERROR_MESSAGES.DEFAULT(e, 'Error loading reranking model'))
else:
if engine == 'external':
try:
from open_webui.retrieval.models.external import ExternalReranker
import sentence_transformers
import torch
rf = ExternalReranker(
url=external_reranker_url,
api_key=external_reranker_api_key,
model=reranking_model,
timeout=timeout_value,
)
except Exception as e:
log.error(f'ExternalReranking: {e}')
raise Exception(ERROR_MESSAGES.DEFAULT(e, 'Error loading reranking model'))
else:
import sentence_transformers
import torch
try:
rf = sentence_transformers.CrossEncoder(
get_model_path(reranking_model, auto_update),
device=DEVICE_TYPE,
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
activation_fn=(
torch.nn.Sigmoid() if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION else None
),
)
except Exception as e:
log.error(f'CrossEncoder: {e}')
raise Exception(ERROR_MESSAGES.DEFAULT(e, 'CrossEncoder error'))
try:
rf = sentence_transformers.CrossEncoder(
get_model_path(reranking_model, auto_update),
device=DEVICE_TYPE,
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
activation_fn=(
torch.nn.Sigmoid()
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION
else None
),
)
except Exception as e:
log.error(f'CrossEncoder: {e}')
raise Exception(ERROR_MESSAGES.DEFAULT(e, 'CrossEncoder error'))
# Safely adjust pad_token_id if missing as some models do not have this in config
try:
model_cfg = getattr(rf, 'model', None)
if model_cfg and hasattr(model_cfg, 'config'):
cfg = model_cfg.config
if getattr(cfg, 'pad_token_id', None) is None:
# Fallback to eos_token_id when available
eos = getattr(cfg, 'eos_token_id', None)
if eos is not None:
cfg.pad_token_id = eos
log.debug('Missing pad_token_id detected; set to eos_token_id=%s', eos)
else:
log.warning('Neither pad_token_id nor eos_token_id present in model config')
except Exception as e2:
log.warning(f'Failed to adjust pad_token_id on CrossEncoder: {e2}')
# Safely adjust pad_token_id if missing as some models do not have this in config
try:
model_cfg = getattr(rf, 'model', None)
if model_cfg and hasattr(model_cfg, 'config'):
cfg = model_cfg.config
if getattr(cfg, 'pad_token_id', None) is None:
# Fallback to eos_token_id when available
eos = getattr(cfg, 'eos_token_id', None)
if eos is not None:
cfg.pad_token_id = eos
log.debug('Missing pad_token_id detected; set to eos_token_id=%s', eos)
else:
log.warning('Neither pad_token_id nor eos_token_id present in model config')
except Exception as e2:
log.warning(f'Failed to adjust pad_token_id on CrossEncoder: {e2}')
return rf
@ -535,6 +531,8 @@ async def unload_embedding_model(request: Request):
@router.post('/embedding/update')
async def update_embedding_config(request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)):
if USE_SLIM and form_data.RAG_EMBEDDING_ENGINE == '':
raise HTTPException(400, 'Slim requires an external embedding engine (openai, ollama, azure_openai).')
config = await get_retrieval_config()
log.info('Updating embedding model: %s to %s', config.RAG_EMBEDDING_MODEL, form_data.RAG_EMBEDDING_MODEL)
await unload_embedding_model(request)
@ -952,6 +950,44 @@ class ConfigForm(BaseModel):
async def update_rag_config(request: Request, form_data: ConfigForm, user=Depends(get_admin_user)):
# RAG settings
config = await get_retrieval_config()
if USE_SLIM:
if form_data.web:
web_engine = (
form_data.web.WEB_LOADER_ENGINE
if form_data.web.WEB_LOADER_ENGINE is not None
else config.WEB_LOADER_ENGINE
)
browser_url = (
form_data.web.PLAYWRIGHT_WS_URL
if form_data.web.PLAYWRIGHT_WS_URL is not None
else config.PLAYWRIGHT_WS_URL
)
if (
web_engine == 'playwright'
and not browser_url
and (web_engine != config.WEB_LOADER_ENGINE or browser_url != config.PLAYWRIGHT_WS_URL)
):
raise HTTPException(400, 'Configure PLAYWRIGHT_WS_URL. Slim requires a remote browser.')
if form_data.TEXT_SPLITTER == 'token_transformers' and config.TEXT_SPLITTER != 'token_transformers':
raise HTTPException(
400, 'Transformers tokenization is unavailable in slim. Use character or token splitting.'
)
reranker_engine = (
form_data.RAG_RERANKING_ENGINE
if form_data.RAG_RERANKING_ENGINE is not None
else config.RAG_RERANKING_ENGINE
)
reranker_model = (
form_data.RAG_RERANKING_MODEL if form_data.RAG_RERANKING_MODEL is not None else config.RAG_RERANKING_MODEL
)
if (
reranker_engine != 'external'
and reranker_model
and (reranker_engine != config.RAG_RERANKING_ENGINE or reranker_model != config.RAG_RERANKING_MODEL)
):
raise HTTPException(
400, 'Slim requires an external reranker, or an empty reranking model for cosine scoring.'
)
config.RAG_TEMPLATE = form_data.RAG_TEMPLATE if form_data.RAG_TEMPLATE is not None else config.RAG_TEMPLATE
config.TOP_K = form_data.TOP_K if form_data.TOP_K is not None else config.TOP_K
config.BYPASS_EMBEDDING_AND_RETRIEVAL = (
@ -1589,6 +1625,8 @@ def merge_docs_to_target_size(
def get_transformers_tokenizer(request: Request, config: RetrievalConfig):
if USE_SLIM:
raise HTTPException(503, 'Transformers tokenization is unavailable in slim. Use character or token splitting.')
if config.RAG_TOKENIZER_MODEL:
from transformers import AutoTokenizer
@ -1673,7 +1711,7 @@ def save_docs_to_vector_db(
# Check if entries with the same hash (metadata.hash) already exist
if metadata and 'hash' in metadata:
result = VECTOR_DB_CLIENT.query(
result = get_vector_db_client().query(
collection_name=collection_name,
filter={'hash': metadata['hash']},
)
@ -1773,11 +1811,11 @@ def save_docs_to_vector_db(
]
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name):
if get_vector_db_client().has_collection(collection_name=collection_name):
log.info('collection %s already exists', collection_name)
if overwrite:
VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name)
get_vector_db_client().delete_collection(collection_name=collection_name)
log.info('deleting existing collection %s', collection_name)
elif add is False:
log.info('collection %s already exists, overwrite is False and add is False', collection_name)
@ -1840,7 +1878,7 @@ def save_docs_to_vector_db(
]
log.info('adding to collection %s', collection_name)
VECTOR_DB_CLIENT.insert(
get_vector_db_client().insert(
collection_name=collection_name,
items=items,
)
@ -3035,7 +3073,7 @@ async def query_doc_handler(
query_embedding = await request.app.state.EMBEDDING_FUNCTION(
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
)
# query_doc wraps a blocking VECTOR_DB_CLIENT.search call;
# query_doc wraps a blocking get_vector_db_client().search call;
# offload so the request's event loop stays responsive.
return await asyncio.to_thread(
query_doc,

View file

@ -1,54 +0,0 @@
# Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image
fastapi==0.136.3
uvicorn[standard]==0.51.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
orjson==3.11.9
cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.13.0
authlib==1.7.2
joserfc==1.7.4
requests==2.34.2
aiohttp==3.13.5 # do not update to 3.13.3 - broken
aiocache
aiofiles
starlette-compress==1.7.1
Brotli==1.2.0
brotlicffi==1.2.0.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.50
aiosqlite==0.22.1
psycopg[binary]==3.3.4
alembic==1.18.4
pycrdt==0.13.1
redis
hiredis
loguru==0.7.3
asgiref==3.11.1
mcp==1.27.2
openai
langchain-community==0.4.2
langchain-classic==1.0.7
langchain-text-splitters==1.1.2
fake-useragent==2.2.0
chromadb==1.5.9
black==26.5.1
pydub
chardet==7.4.3
beautifulsoup4

View file

@ -0,0 +1,122 @@
# External-services image. Keep shared package pins aligned with requirements.txt.
# NumPy is used for cosine scoring; pandas is required by the remote Milvus client.
numpy==2.4.6
typer==0.25.1
fastapi==0.136.3
uvicorn[standard]==0.51.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
orjson==3.11.9
cryptography==48.0.0
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.13.0
authlib==1.7.2
joserfc==1.7.4
requests==2.34.2
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==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER
aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.1
Brotli==1.2.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
sqlalchemy[asyncio]==2.0.50
aiosqlite==0.22.1
psycopg[binary]==3.3.4
alembic==1.18.4
pycrdt==0.13.1
redis==8.0.1
hiredis==3.4.0
APScheduler==3.11.2
pytz==2026.2
loguru==0.7.3
asgiref==3.11.1
# AI libraries
tiktoken==0.13.0
mcp==1.27.2
openai==2.29.0
langchain-community==0.4.2
langchain-classic==1.0.7
langchain-text-splitters==1.1.2
fake-useragent==2.2.0
chromadb-client==1.5.9
weaviate-client==4.20.3
opensearch-py==3.2.0
ftfy==6.3.1
chardet==7.4.3
fpdf2==2.8.7
Markdown==3.10.2
beautifulsoup4==4.14.3
lxml==6.1.1
pandas==3.0.3
validators==0.35.0
psutil==7.2.2
pillow==12.2.0
rank-bm25==0.2.2
black==26.5.1
youtube-transcript-api==1.2.4
ddgs==9.14.4
azure-ai-documentintelligence==1.0.2
azure-identity==1.25.3
azure-storage-blob==12.29.0
azure-search-documents==12.0.0
## Google Cloud storage
googleapis-common-protos==1.75.0
google-cloud-storage==3.9.0
## Databases
psycopg2-binary==2.9.12
pgvector==0.4.2
PyMySQL==1.2.0
boto3==1.42.62
# mariadb==1.1.14 should be added if you want to support MariaDB
# valkey-glide-sync==2.3.1 # optional: install manually if VECTOR_DB=valkey
pymilvus==2.6.14
qdrant-client==1.18.0
playwright==1.60.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.4.1
pinecone==6.0.2
oracledb==3.4.2
## LDAP
ldap3==2.9.1
## Trace
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-exporter-otlp==1.42.1
opentelemetry-instrumentation==0.63b1
opentelemetry-instrumentation-fastapi==0.63b1
opentelemetry-instrumentation-sqlalchemy==0.63b1
opentelemetry-instrumentation-redis==0.63b1
opentelemetry-instrumentation-requests==0.63b1
opentelemetry-instrumentation-logging==0.63b1
opentelemetry-instrumentation-httpx==0.63b1
opentelemetry-instrumentation-aiohttp-client==0.63b1
opentelemetry-instrumentation-system-metrics==0.63b1

View file

@ -11,14 +11,14 @@ set -euo pipefail
# expansion. The two can't be combined inline (`${VAR:-default,,}` makes
# the default literal `,,`), so we normalise once up front and the simple
# `${VAR,,}` form stays safe under `set -u` everywhere else.
: "${WEB_LOADER_ENGINE:=}" "${USE_OLLAMA_DOCKER:=}" "${USE_CUDA_DOCKER:=}"
: "${USE_SLIM_DOCKER:=}" "${WEB_LOADER_ENGINE:=}" "${USE_OLLAMA_DOCKER:=}" "${USE_CUDA_DOCKER:=}"
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
cd "$SCRIPT_DIR" || exit 1
# ── Playwright browser installation (if configured) ──────────────────────────
if [[ "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then
if [[ "${USE_SLIM_DOCKER,,}" != "true" && "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then
if [[ -z "${PLAYWRIGHT_WS_URL:-}" ]]; then
echo "Installing Playwright Chromium browser..."
playwright install chromium

View file

@ -245,6 +245,13 @@
}}
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('Audio')}</h2>
{#if $config?.features?.slim === true}
<p class="mb-4 text-xs text-gray-500">
{$i18n.t(
'Slim requires external speech providers. Local speech models and audio conversion are unavailable.'
)}
</p>
{/if}
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<AdminSettingSection title={$i18n.t('Speech-to-Text')} first>
@ -253,7 +260,9 @@
description={$i18n.t('Choose the transcription provider used for audio input.')}
>
<SettingsSelect bind:value={STT_ENGINE} placeholder={$i18n.t('Select an engine')}>
<option value="">{$i18n.t('Whisper (Local)')}</option>
<option value="" disabled={$config?.features?.slim === true}
>{$i18n.t('Whisper (Local)')}</option
>
<option value="openai">{$i18n.t('OpenAI')}</option>
<option value="web">{$i18n.t('Web API')}</option>
<option value="deepgram">{$i18n.t('Deepgram')}</option>
@ -500,7 +509,9 @@
}}
>
<option value="">{$i18n.t('Web API')}</option>
<option value="transformers">{$i18n.t('Transformers')} ({$i18n.t('Local')})</option>
<option value="transformers" disabled={$config?.features?.slim === true}
>{$i18n.t('Transformers')} ({$i18n.t('Local')})</option
>
<option value="openai">{$i18n.t('OpenAI')}</option>
<option value="elevenlabs">{$i18n.t('ElevenLabs')}</option>
<option value="azure">{$i18n.t('Azure AI Speech')}</option>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { config } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { onMount, getContext, createEventDispatcher } from 'svelte';
@ -421,6 +422,13 @@
}}
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('Documents')}</h2>
{#if $config?.features?.slim === true}
<p class="mb-4 text-xs text-gray-500">
{$i18n.t(
'Slim requires external services for embeddings, vector storage, and document extraction. Basic text files can be read locally.'
)}
</p>
{/if}
{#if RAGConfig}
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
@ -430,7 +438,11 @@
description={$i18n.t('Choose how uploaded documents are parsed before indexing.')}
>
<SettingsSelect bind:value={RAGConfig.CONTENT_EXTRACTION_ENGINE}>
<option value="">{$i18n.t('Default')}</option>
<option value=""
>{$config?.features?.slim === true
? $i18n.t('Basic text only')
: $i18n.t('Default')}</option
>
<option value="external">{$i18n.t('External')}</option>
<option value="tika">{$i18n.t('Tika')}</option>
<option value="docling">{$i18n.t('Docling')}</option>
@ -455,7 +467,7 @@
/>
</AdminSettingField>
{#if RAGConfig.CONTENT_EXTRACTION_ENGINE === ''}
{#if RAGConfig.CONTENT_EXTRACTION_ENGINE === '' && $config?.features?.slim !== true}
<AdminSettingRow
label={$i18n.t('PDF Extract Images (OCR)')}
description={$i18n.t('Extract images from PDFs so OCR can process image-only pages.')}
@ -908,7 +920,7 @@
<SettingsSelect bind:value={RAGConfig.TEXT_SPLITTER}>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
<option value="token_transformers">
<option value="token_transformers" disabled={$config?.features?.slim === true}>
{$i18n.t('Token')} ({$i18n.t('Transformers')})
</option>
</SettingsSelect>
@ -1012,7 +1024,9 @@
}
}}
>
<option value="">{$i18n.t('Default (SentenceTransformers)')}</option>
<option value="" disabled={$config?.features?.slim === true}
>{$i18n.t('Default (SentenceTransformers)')}</option
>
<option value="ollama">{$i18n.t('Ollama')}</option>
<option value="openai">{$i18n.t('OpenAI')}</option>
<option value="azure_openai">{$i18n.t('Azure OpenAI')}</option>
@ -1251,7 +1265,9 @@
}
}}
>
<option value="">{$i18n.t('Default (SentenceTransformers)')}</option>
<option value="" disabled={$config?.features?.slim === true}
>{$i18n.t('Default (SentenceTransformers)')}</option
>
<option value="external">{$i18n.t('External')}</option>
</SettingsSelect>
</AdminSettingRow>

View file

@ -336,6 +336,7 @@ type Config = {
default_prompt_suggestions: PromptSuggestion[];
default_prompt_suggestions_i18n?: Record<string, { suggestion_prompts: PromptSuggestion[] }>;
features: {
slim?: boolean;
auth: boolean;
auth_trusted_header: boolean;
enable_api_keys: boolean;