Merge branch 'open-webui:main' into s3vector-support

This commit is contained in:
James W.
2025-08-19 22:06:16 -06:00
committed by GitHub
260 changed files with 14327 additions and 4492 deletions
+116 -19
View File
@@ -7,7 +7,7 @@ import redis
from datetime import datetime
from pathlib import Path
from typing import Generic, Optional, TypeVar
from typing import Generic, Union, Optional, TypeVar
from urllib.parse import urlparse
import requests
@@ -168,9 +168,19 @@ class PersistentConfig(Generic[T]):
self.config_path = config_path
self.env_value = env_value
self.config_value = get_config_value(config_path)
if self.config_value is not None and ENABLE_PERSISTENT_CONFIG:
log.info(f"'{env_name}' loaded from the latest database entry")
self.value = self.config_value
if (
self.config_path.startswith("oauth.")
and not ENABLE_OAUTH_PERSISTENT_CONFIG
):
log.info(
f"Skipping loading of '{env_name}' as OAuth persistent config is disabled"
)
self.value = env_value
else:
log.info(f"'{env_name}' loaded from the latest database entry")
self.value = self.config_value
else:
self.value = env_value
@@ -213,13 +223,14 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str
def __init__(
self,
redis_url: Optional[str] = None,
redis_sentinels: Optional[list] = [],
redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui",
):
super().__setattr__("_state", {})
@@ -227,7 +238,12 @@ class AppConfig:
if redis_url:
super().__setattr__(
"_redis",
get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
get_redis_connection(
redis_url,
redis_sentinels,
redis_cluster,
decode_responses=True,
),
)
def __setattr__(self, key, value):
@@ -296,6 +312,9 @@ JWT_EXPIRES_IN = PersistentConfig(
# OAuth config
####################################
ENABLE_OAUTH_PERSISTENT_CONFIG = (
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true"
)
ENABLE_OAUTH_SIGNUP = PersistentConfig(
"ENABLE_OAUTH_SIGNUP",
@@ -463,6 +482,12 @@ OAUTH_PROVIDER_NAME = PersistentConfig(
os.environ.get("OAUTH_PROVIDER_NAME", "SSO"),
)
OAUTH_SUB_CLAIM = PersistentConfig(
"OAUTH_SUB_CLAIM",
"oauth.oidc.sub_claim",
os.environ.get("OAUTH_SUB_CLAIM", None),
)
OAUTH_USERNAME_CLAIM = PersistentConfig(
"OAUTH_USERNAME_CLAIM",
"oauth.oidc.username_claim",
@@ -680,6 +705,23 @@ def load_oauth_providers():
"register": oidc_oauth_register,
}
configured_providers = []
if GOOGLE_CLIENT_ID.value:
configured_providers.append("Google")
if MICROSOFT_CLIENT_ID.value:
configured_providers.append("Microsoft")
if GITHUB_CLIENT_ID.value:
configured_providers.append("GitHub")
if configured_providers and not OPENID_PROVIDER_URL.value:
provider_list = ", ".join(configured_providers)
log.warning(
f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!"
)
log.warning(
f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality."
)
load_oauth_providers()
@@ -779,12 +821,6 @@ if CUSTOM_NAME:
pass
####################################
# LICENSE_KEY
####################################
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
####################################
# STORAGE PROVIDER
####################################
@@ -835,7 +871,7 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
ENABLE_DIRECT_CONNECTIONS = PersistentConfig(
"ENABLE_DIRECT_CONNECTIONS",
"direct.enable",
os.environ.get("ENABLE_DIRECT_CONNECTIONS", "True").lower() == "true",
os.environ.get("ENABLE_DIRECT_CONNECTIONS", "False").lower() == "true",
)
####################################
@@ -1149,10 +1185,18 @@ USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_VALVES = (
os.environ.get("USER_PERMISSIONS_CHAT_VALVES", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = (
os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_PARAMS = (
os.environ.get("USER_PERMISSIONS_CHAT_PARAMS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
@@ -1238,7 +1282,9 @@ DEFAULT_USER_PERMISSIONS = {
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"valves": USER_PERMISSIONS_CHAT_VALVES,
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
@@ -1305,6 +1351,10 @@ WEBHOOK_URL = PersistentConfig(
ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true"
ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = (
os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true"
)
ENABLE_ADMIN_CHAT_ACCESS = (
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
)
@@ -1343,10 +1393,11 @@ if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
def validate_cors_origin(origin):
parsed_url = urlparse(origin)
# Check if the scheme is either http or https
if parsed_url.scheme not in ["http", "https"]:
# Check if the scheme is either http or https, or a custom scheme
schemes = ["http", "https"] + CORS_ALLOW_CUSTOM_SCHEME
if parsed_url.scheme not in schemes:
raise ValueError(
f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' are allowed."
f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' and CORS_ALLOW_CUSTOM_SCHEME are allowed."
)
# Ensure that the netloc (domain + port) is present, indicating it's a valid URL
@@ -1361,6 +1412,11 @@ def validate_cors_origin(origin):
# in your .env file depending on your frontend port, 5173 in this case.
CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
# Allows custom URL schemes (e.g., app://) to be used as origins for CORS.
# Useful for local development or desktop clients with schemes like app:// or other custom protocols.
# Provide a semicolon-separated list of allowed schemes in the environment variable CORS_ALLOW_CUSTOM_SCHEMES.
CORS_ALLOW_CUSTOM_SCHEME = os.environ.get("CORS_ALLOW_CUSTOM_SCHEME", "").split(";")
if CORS_ALLOW_ORIGIN == ["*"]:
log.warning(
"\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n"
@@ -1868,6 +1924,8 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true"
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
QDRANT_TIMEOUT = int(os.environ.get("QDRANT_TIMEOUT", "5"))
QDRANT_HNSW_M = int(os.environ.get("QDRANT_HNSW_M", "16"))
ENABLE_QDRANT_MULTITENANCY_MODE = (
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
)
@@ -1957,6 +2015,33 @@ PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024
PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine")
PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure"
# ORACLE23AI (Oracle23ai Vector Search)
ORACLE_DB_USE_WALLET = os.environ.get("ORACLE_DB_USE_WALLET", "false").lower() == "true"
ORACLE_DB_USER = os.environ.get("ORACLE_DB_USER", None) #
ORACLE_DB_PASSWORD = os.environ.get("ORACLE_DB_PASSWORD", None) #
ORACLE_DB_DSN = os.environ.get("ORACLE_DB_DSN", None) #
ORACLE_WALLET_DIR = os.environ.get("ORACLE_WALLET_DIR", None)
ORACLE_WALLET_PASSWORD = os.environ.get("ORACLE_WALLET_PASSWORD", None)
ORACLE_VECTOR_LENGTH = os.environ.get("ORACLE_VECTOR_LENGTH", 768)
ORACLE_DB_POOL_MIN = int(os.environ.get("ORACLE_DB_POOL_MIN", 2))
ORACLE_DB_POOL_MAX = int(os.environ.get("ORACLE_DB_POOL_MAX", 10))
ORACLE_DB_POOL_INCREMENT = int(os.environ.get("ORACLE_DB_POOL_INCREMENT", 1))
if 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):
raise ValueError(
"Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication."
)
log.info(f"VECTOR_DB: {VECTOR_DB}")
# S3 Vector
S3_VECTOR_BUCKET_NAME = os.environ.get("S3_VECTOR_BUCKET_NAME", None)
S3_VECTOR_REGION = os.environ.get("S3_VECTOR_REGION", None)
@@ -2022,10 +2107,16 @@ DATALAB_MARKER_API_KEY = PersistentConfig(
os.environ.get("DATALAB_MARKER_API_KEY", ""),
)
DATALAB_MARKER_LANGS = PersistentConfig(
"DATALAB_MARKER_LANGS",
"rag.datalab_marker_langs",
os.environ.get("DATALAB_MARKER_LANGS", ""),
DATALAB_MARKER_API_BASE_URL = PersistentConfig(
"DATALAB_MARKER_API_BASE_URL",
"rag.datalab_marker_api_base_url",
os.environ.get("DATALAB_MARKER_API_BASE_URL", ""),
)
DATALAB_MARKER_ADDITIONAL_CONFIG = PersistentConfig(
"DATALAB_MARKER_ADDITIONAL_CONFIG",
"rag.datalab_marker_additional_config",
os.environ.get("DATALAB_MARKER_ADDITIONAL_CONFIG", ""),
)
DATALAB_MARKER_USE_LLM = PersistentConfig(
@@ -2065,6 +2156,12 @@ DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig(
== "true",
)
DATALAB_MARKER_FORMAT_LINES = PersistentConfig(
"DATALAB_MARKER_FORMAT_LINES",
"rag.datalab_marker_format_lines",
os.environ.get("DATALAB_MARKER_FORMAT_LINES", "false").lower() == "true",
)
DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig(
"DATALAB_MARKER_OUTPUT_FORMAT",
"rag.datalab_marker_output_format",
+108 -1
View File
@@ -7,6 +7,7 @@ import sys
import shutil
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
import markdown
from bs4 import BeautifulSoup
@@ -287,6 +288,9 @@ DB_VARS = {
if all(DB_VARS.values()):
DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"):
# Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set
DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db"
# Replace the postgres:// with postgresql://
if "postgres://" in DATABASE_URL:
@@ -345,7 +349,10 @@ ENABLE_REALTIME_CHAT_SAVE = (
####################################
REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true"
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
@@ -377,6 +384,10 @@ except ValueError:
####################################
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
)
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
@@ -430,6 +441,41 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
)
####################################
# SCIM Configuration
####################################
SCIM_ENABLED = os.environ.get("SCIM_ENABLED", "False").lower() == "true"
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
####################################
# LICENSE_KEY
####################################
LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
LICENSE_BLOB = None
LICENSE_BLOB_PATH = os.environ.get("LICENSE_BLOB_PATH", DATA_DIR / "l.data")
if LICENSE_BLOB_PATH and os.path.exists(LICENSE_BLOB_PATH):
with open(LICENSE_BLOB_PATH, "rb") as f:
LICENSE_BLOB = f.read()
LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
pk = None
if LICENSE_PUBLIC_KEY:
pk = serialization.load_pem_public_key(
f"""
-----BEGIN PUBLIC KEY-----
{LICENSE_PUBLIC_KEY}
-----END PUBLIC KEY-----
""".encode(
"utf-8"
)
)
####################################
# MODELS
####################################
@@ -444,6 +490,25 @@ else:
MODELS_CACHE_TTL = 1
####################################
# CHAT
####################################
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get(
"CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1"
)
if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "":
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
else:
try:
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int(
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE
)
except Exception:
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
####################################
# WEBSOCKET SUPPORT
####################################
@@ -456,6 +521,9 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
WEBSOCKET_REDIS_CLUSTER = (
os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true"
)
websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
@@ -465,9 +533,9 @@ except ValueError:
WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "":
@@ -610,12 +678,32 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true"
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
)
OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
)
OTEL_EXPORTER_OTLP_INSECURE = (
os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
)
OTEL_METRICS_EXPORTER_OTLP_INSECURE = (
os.environ.get(
"OTEL_METRICS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
).lower()
== "true"
)
OTEL_LOGS_EXPORTER_OTLP_INSECURE = (
os.environ.get(
"OTEL_LOGS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
).lower()
== "true"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
@@ -626,11 +714,30 @@ OTEL_TRACES_SAMPLER = os.environ.get(
OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get(
"OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
)
OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get(
"OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
)
OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get(
"OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
)
OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get(
"OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
)
OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_OTLP_SPAN_EXPORTER", "grpc"
).lower() # grpc or http
OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
).lower() # grpc or http
OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
"OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
).lower() # grpc or http
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
+32 -1
View File
@@ -1,3 +1,4 @@
import os
import json
import logging
from contextlib import contextmanager
@@ -79,7 +80,37 @@ handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
if "sqlite" in SQLALCHEMY_DATABASE_URL:
# Handle SQLCipher URLs
if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
# Extract database path from SQLCipher URL
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{database_password}'")
return conn
engine = create_engine(
"sqlite://", # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
log.info("Connected to encrypted SQLite database using SQLCipher")
elif "sqlite" in SQLALCHEMY_DATABASE_URL:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
+40 -16
View File
@@ -1,4 +1,5 @@
import logging
import os
from contextvars import ContextVar
from open_webui.env import SRC_LOG_LEVELS
@@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url):
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
# Check if using SQLCipher protocol
if db_url.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to PostgreSQL database")
log.info("Connected to encrypted SQLite database using SQLCipher")
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to SQLite database")
else:
raise ValueError("Unsupported database connection")
# Standard database connection (existing logic)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to PostgreSQL database")
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to SQLite database")
else:
raise ValueError("Unsupported database connection")
return db
+83 -20
View File
@@ -85,6 +85,7 @@ from open_webui.routers import (
tools,
users,
utils,
scim,
)
from open_webui.routers.retrieval import (
@@ -102,7 +103,6 @@ from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.config import (
LICENSE_KEY,
# Ollama
ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS,
@@ -227,12 +227,14 @@ from open_webui.config import (
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
DATALAB_MARKER_LANGS,
DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_OUTPUT_FORMAT,
DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL,
@@ -395,10 +397,12 @@ from open_webui.config import (
reset_config,
)
from open_webui.env import (
LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
REDIS_CLUSTER,
REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
@@ -412,9 +416,13 @@ from open_webui.env import (
WEBUI_SECRET_KEY,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
# SCIM
SCIM_ENABLED,
SCIM_TOKEN,
ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
@@ -462,6 +470,9 @@ from open_webui.tasks import (
from open_webui.utils.redis import get_sentinels_from_env
from open_webui.constants import ERROR_MESSAGES
if SAFE_MODE:
print("SAFE MODE ENABLED")
Functions.deactivate_all_functions()
@@ -524,6 +535,7 @@ async def lifespan(app: FastAPI):
redis_sentinels=get_sentinels_from_env(
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
),
redis_cluster=REDIS_CLUSTER,
async_mode=True,
)
@@ -579,6 +591,7 @@ app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
redis_cluster=REDIS_CLUSTER,
redis_key_prefix=REDIS_KEY_PREFIX,
)
app.state.redis = None
@@ -642,6 +655,15 @@ app.state.TOOL_SERVERS = []
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
########################################
#
# SCIM
#
########################################
app.state.SCIM_ENABLED = SCIM_ENABLED
app.state.SCIM_TOKEN = SCIM_TOKEN
########################################
#
# MODELS
@@ -767,7 +789,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY
app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS
app.state.config.DATALAB_MARKER_API_BASE_URL = DATALAB_MARKER_API_BASE_URL
app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = DATALAB_MARKER_ADDITIONAL_CONFIG
app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE
app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR
app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE
@@ -775,6 +798,7 @@ app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTI
app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES
app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM
app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT
app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
@@ -1211,6 +1235,10 @@ app.include_router(
)
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management
if SCIM_ENABLED:
app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["scim"])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
@@ -1233,6 +1261,7 @@ if audit_level != AuditLevel.NONE:
@app.get("/api/models")
@app.get("/api/v1/models") # Experimental: Compatibility with OpenAI API
async def get_models(
request: Request, refresh: bool = False, user=Depends(get_verified_user)
):
@@ -1296,7 +1325,7 @@ async def get_models(
models = get_filtered_models(models, user)
log.debug(
f"/api/models returned filtered models accessible to the user: {json.dumps([model['id'] for model in models])}"
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
)
return {"data": models}
@@ -1313,6 +1342,7 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
@app.post("/api/embeddings")
@app.post("/api/v1/embeddings") # Experimental: Compatibility with OpenAI API
async def embeddings(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
@@ -1339,6 +1369,7 @@ async def embeddings(
@app.post("/api/chat/completions")
@app.post("/api/v1/chat/completions") # Experimental: Compatibility with OpenAI API
async def chat_completion(
request: Request,
form_data: dict,
@@ -1347,13 +1378,13 @@ async def chat_completion(
if not request.app.state.MODELS:
await get_all_models(request, user=user)
model_id = form_data.get("model", None)
model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None)
metadata = {}
try:
if not model_item.get("direct", False):
model_id = form_data.get("model", None)
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
@@ -1373,6 +1404,19 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
model_info_params = (
model_info.params.model_dump() if model_info and model_info.params else {}
)
# Chat Params
stream_delta_chunk_size = form_data.get("params", {}).get(
"stream_delta_chunk_size"
)
# Model Params
if model_info_params.get("stream_delta_chunk_size"):
stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size")
metadata = {
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
@@ -1386,25 +1430,33 @@ async def chat_completion(
"variables": form_data.get("variables", {}),
"model": model,
"direct": model_item.get("direct", False),
**(
{"function_calling": "native"}
if form_data.get("params", {}).get("function_calling") == "native"
or (
model_info
and model_info.params.model_dump().get("function_calling")
== "native"
)
else {}
),
"params": {
"stream_delta_chunk_size": stream_delta_chunk_size,
"function_calling": (
"native"
if (
form_data.get("params", {}).get("function_calling") == "native"
or model_info_params.get("function_calling") == "native"
)
else "default"
),
},
}
if metadata.get("chat_id") and (user and user.role != "admin"):
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
request.state.metadata = metadata
form_data["metadata"] = metadata
form_data, metadata, events = await process_chat_payload(
request, form_data, user, metadata, model
)
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
if metadata.get("chat_id") and metadata.get("message_id"):
@@ -1424,6 +1476,14 @@ async def chat_completion(
try:
response = await chat_completion_handler(request, form_data, user)
if metadata.get("chat_id") and metadata.get("message_id"):
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"model": model_id,
},
)
return await process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
@@ -1563,6 +1623,7 @@ async def get_app_config(request: Request):
"features": {
"auth": WEBUI_AUTH,
"auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER),
"enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
"enable_ldap": app.state.config.ENABLE_LDAP,
"enable_api_key": app.state.config.ENABLE_API_KEY,
"enable_signup": app.state.config.ENABLE_SIGNUP,
@@ -1641,14 +1702,17 @@ async def get_app_config(request: Request):
else {}
),
}
if user is not None
if user is not None and (user.role in ["admin", "user"])
else {
**(
{
"metadata": {
"login_footer": app.state.LICENSE_METADATA.get(
"login_footer", ""
)
),
"auth_logo_position": app.state.LICENSE_METADATA.get(
"auth_logo_position", ""
),
}
}
if app.state.LICENSE_METADATA
@@ -1765,11 +1829,10 @@ async def get_manifest_json():
return {
"name": app.state.WEBUI_NAME,
"short_name": app.state.WEBUI_NAME,
"description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.",
"description": f"{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.",
"start_url": "/",
"display": "standalone",
"background_color": "#343541",
"orientation": "any",
"icons": [
{
"src": "/static/logo.png",
+34 -7
View File
@@ -2,8 +2,8 @@ from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.env import DATABASE_URL
from sqlalchemy import engine_from_config, pool
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -62,11 +62,38 @@ def run_migrations_online() -> None:
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
# Extract database path from SQLCipher URL
db_path = DB_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return conn
connectable = create_engine(
"sqlite://", # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
else:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
+60 -3
View File
@@ -6,6 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
@@ -296,6 +297,9 @@ class ChatTable:
"user_id": f"shared-{chat_id}",
"title": chat.title,
"chat": chat.chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
"created_at": chat.created_at,
"updated_at": int(time.time()),
}
@@ -327,7 +331,9 @@ class ChatTable:
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.meta = chat.meta
shared_chat.pinned = chat.pinned
shared_chat.folder_id = chat.folder_id
shared_chat.updated_at = int(time.time())
db.commit()
db.refresh(shared_chat)
@@ -612,8 +618,45 @@ class ChatTable:
if word.startswith("tag:")
]
# Extract folder names - handle spaces and case insensitivity
folders = Folders.search_folders_by_names(
user_id,
[
word.replace("folder:", "")
for word in search_text_words
if word.startswith("folder:")
],
)
folder_ids = [folder.id for folder in folders]
is_pinned = None
if "pinned:true" in search_text_words:
is_pinned = True
elif "pinned:false" in search_text_words:
is_pinned = False
is_archived = None
if "archived:true" in search_text_words:
is_archived = True
elif "archived:false" in search_text_words:
is_archived = False
is_shared = None
if "shared:true" in search_text_words:
is_shared = True
elif "shared:false" in search_text_words:
is_shared = False
search_text_words = [
word for word in search_text_words if not word.startswith("tag:")
word
for word in search_text_words
if (
not word.startswith("tag:")
and not word.startswith("folder:")
and not word.startswith("pinned:")
and not word.startswith("archived:")
and not word.startswith("shared:")
)
]
search_text = " ".join(search_text_words)
@@ -621,9 +664,23 @@ class ChatTable:
with get_db() as db:
query = db.query(Chat).filter(Chat.user_id == user_id)
if not include_archived:
if is_archived is not None:
query = query.filter(Chat.archived == is_archived)
elif not include_archived:
query = query.filter(Chat.archived == False)
if is_pinned is not None:
query = query.filter(Chat.pinned == is_pinned)
if is_shared is not None:
if is_shared:
query = query.filter(Chat.share_id.isnot(None))
else:
query = query.filter(Chat.share_id.is_(None))
if folder_ids:
query = query.filter(Chat.folder_id.in_(folder_ids))
query = query.order_by(Chat.updated_at.desc())
# Check if the database dialect is either 'sqlite' or 'postgresql'
+65 -19
View File
@@ -2,14 +2,14 @@ import logging
import time
import uuid
from typing import Optional
import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from open_webui.internal.db import Base, get_db
from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
@@ -106,7 +106,7 @@ class FolderTable:
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[FolderModel]:
) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
folders = []
@@ -251,18 +251,15 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
try:
folder_ids = []
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return False
return folder_ids
if delete_chats:
# Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
folder_ids.append(folder.id)
# Delete all children folders
def delete_children(folder):
@@ -270,12 +267,9 @@ class FolderTable:
folder.id, user_id
)
for folder_child in folder_children:
if delete_chats:
Chats.delete_chats_by_user_id_and_folder_id(
user_id, folder_child.id
)
delete_children(folder_child)
folder_ids.append(folder_child.id)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
db.delete(folder)
@@ -284,10 +278,62 @@ class FolderTable:
delete_children(folder)
db.delete(folder)
db.commit()
return True
return folder_ids
except Exception as e:
log.error(f"delete_folder: {e}")
return False
return []
def normalize_folder_name(self, name: str) -> str:
# Replace _ and space with a single space, lower case, collapse multiple spaces
name = re.sub(r"[\s_]+", " ", name)
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str]
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
"""
normalized_queries = [self.normalize_folder_name(q) for q in queries]
if not normalized_queries:
return []
results = {}
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
results[folder.id] = FolderModel.model_validate(folder)
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id
)
for child in children:
results[child.id] = child
# Return the results as a list
if not results:
return []
else:
results = list(results.values())
return results
def search_folders_by_name_contains(
self, user_id: str, query: str
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)
if normalized_query in norm_name:
results.append(FolderModel.model_validate(folder))
return results
Folders = FolderTable()
+13 -4
View File
@@ -71,9 +71,13 @@ class MemoriesTable:
) -> Optional[MemoryModel]:
with get_db() as db:
try:
db.query(Memory).filter_by(id=id, user_id=user_id).update(
{"content": content, "updated_at": int(time.time())}
)
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
memory.content = content
memory.updated_at = int(time.time())
db.commit()
return self.get_memory_by_id(id)
except Exception:
@@ -127,7 +131,12 @@ class MemoriesTable:
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
try:
db.query(Memory).filter_by(id=id, user_id=user_id).delete()
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
# Delete the memory
db.delete(memory)
db.commit()
return True
+44
View File
@@ -269,5 +269,49 @@ class ModelsTable:
except Exception:
return False
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
try:
with get_db() as db:
# Get existing models
existing_models = db.query(Model).all()
existing_ids = {model.id for model in existing_models}
# Prepare a set of new model IDs
new_model_ids = {model.id for model in models}
# Update or insert models
for model in models:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
else:
new_model = Model(
**{
**model.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_model)
# Remove models that are no longer present
for model in existing_models:
if model.id not in new_model_ids:
db.delete(model)
db.commit()
return [
ModelModel.model_validate(model) for model in db.query(Model).all()
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
return []
Models = ModelsTable()
+4
View File
@@ -258,6 +258,10 @@ class UsersTable:
with get_db() as db:
return db.query(User).count()
def has_users(self) -> bool:
with get_db() as db:
return db.query(db.query(User).exists()).scalar()
def get_first_user(self) -> UserModel:
try:
with get_db() as db:
@@ -15,24 +15,28 @@ class DatalabMarkerLoader:
self,
file_path: str,
api_key: str,
langs: Optional[str] = None,
api_base_url: str,
additional_config: Optional[str] = None,
use_llm: bool = False,
skip_cache: bool = False,
force_ocr: bool = False,
paginate: bool = False,
strip_existing_ocr: bool = False,
disable_image_extraction: bool = False,
format_lines: bool = False,
output_format: str = None,
):
self.file_path = file_path
self.api_key = api_key
self.langs = langs
self.api_base_url = api_base_url
self.additional_config = additional_config
self.use_llm = use_llm
self.skip_cache = skip_cache
self.force_ocr = force_ocr
self.paginate = paginate
self.strip_existing_ocr = strip_existing_ocr
self.disable_image_extraction = disable_image_extraction
self.format_lines = format_lines
self.output_format = output_format
def _get_mime_type(self, filename: str) -> str:
@@ -60,7 +64,7 @@ class DatalabMarkerLoader:
return mime_map.get(ext, "application/octet-stream")
def check_marker_request_status(self, request_id: str) -> dict:
url = f"https://www.datalab.to/api/v1/marker/{request_id}"
url = f"{self.api_base_url}/marker/{request_id}"
headers = {"X-Api-Key": self.api_key}
try:
response = requests.get(url, headers=headers)
@@ -81,22 +85,24 @@ class DatalabMarkerLoader:
)
def load(self) -> List[Document]:
url = "https://www.datalab.to/api/v1/marker"
filename = os.path.basename(self.file_path)
mime_type = self._get_mime_type(filename)
headers = {"X-Api-Key": self.api_key}
form_data = {
"langs": self.langs,
"use_llm": str(self.use_llm).lower(),
"skip_cache": str(self.skip_cache).lower(),
"force_ocr": str(self.force_ocr).lower(),
"paginate": str(self.paginate).lower(),
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
"disable_image_extraction": str(self.disable_image_extraction).lower(),
"format_lines": str(self.format_lines).lower(),
"output_format": self.output_format,
}
if self.additional_config and self.additional_config.strip():
form_data["additional_config"] = self.additional_config
log.info(
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
)
@@ -105,7 +111,10 @@ class DatalabMarkerLoader:
with open(self.file_path, "rb") as f:
files = {"file": (filename, f, mime_type)}
response = requests.post(
url, data=form_data, files=files, headers=headers
f"{self.api_base_url}/marker",
data=form_data,
files=files,
headers=headers,
)
response.raise_for_status()
result = response.json()
@@ -133,74 +142,92 @@ class DatalabMarkerLoader:
check_url = result.get("request_check_url")
request_id = result.get("request_id")
if not check_url:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned."
)
for _ in range(300): # Up to 10 minutes
time.sleep(2)
try:
poll_response = requests.get(check_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
except (requests.HTTPError, ValueError) as e:
raw_body = poll_response.text
log.error(f"Polling error: {e}, response body: {raw_body}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
)
status_val = poll_result.get("status")
success_val = poll_result.get("success")
if status_val == "complete":
summary = {
k: poll_result.get(k)
for k in (
"status",
"output_format",
"success",
"error",
"page_count",
"total_cost",
# Check if this is a direct response (self-hosted) or polling response (DataLab)
if check_url:
# DataLab polling pattern
for _ in range(300): # Up to 10 minutes
time.sleep(2)
try:
poll_response = requests.get(check_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
except (requests.HTTPError, ValueError) as e:
raw_body = poll_response.text
log.error(f"Polling error: {e}, response body: {raw_body}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
)
}
log.info(
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
)
break
if status_val == "failed" or success_val is False:
log.error(
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
)
error_msg = (
poll_result.get("error")
or "Marker returned failure without error message"
status_val = poll_result.get("status")
success_val = poll_result.get("success")
if status_val == "complete":
summary = {
k: poll_result.get(k)
for k in (
"status",
"output_format",
"success",
"error",
"page_count",
"total_cost",
)
}
log.info(
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
)
break
if status_val == "failed" or success_val is False:
log.error(
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
)
error_msg = (
poll_result.get("error")
or "Marker returned failure without error message"
)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Marker processing failed: {error_msg}",
)
else:
raise HTTPException(
status.HTTP_504_GATEWAY_TIMEOUT,
detail="Marker processing timed out",
)
if not poll_result.get("success", False):
error_msg = poll_result.get("error") or "Unknown processing error"
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Marker processing failed: {error_msg}",
detail=f"Final processing failed: {error_msg}",
)
# DataLab format - content in format-specific fields
content_key = self.output_format.lower()
raw_content = poll_result.get(content_key)
final_result = poll_result
else:
raise HTTPException(
status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
)
# Self-hosted direct response - content in "output" field
if "output" in result:
log.info("Self-hosted Marker returned direct response without polling")
raw_content = result.get("output")
final_result = result
else:
available_fields = (
list(result.keys())
if isinstance(result, dict)
else "non-dict response"
)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.",
)
if not poll_result.get("success", False):
error_msg = poll_result.get("error") or "Unknown processing error"
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Final processing failed: {error_msg}",
)
content_key = self.output_format.lower()
raw_content = poll_result.get(content_key)
if content_key == "json":
if self.output_format.lower() == "json":
full_text = json.dumps(raw_content, indent=2)
elif content_key in {"markdown", "html"}:
elif self.output_format.lower() in {"markdown", "html"}:
full_text = str(raw_content).strip()
else:
raise HTTPException(
@@ -211,14 +238,14 @@ class DatalabMarkerLoader:
if not full_text:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Datalab Marker returned empty content",
detail="Marker returned empty content",
)
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
os.makedirs(marker_output_dir, exist_ok=True)
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
file_ext = file_ext_map.get(content_key, "txt")
file_ext = file_ext_map.get(self.output_format.lower(), "txt")
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
output_path = os.path.join(marker_output_dir, output_filename)
@@ -231,13 +258,13 @@ class DatalabMarkerLoader:
metadata = {
"source": filename,
"output_format": poll_result.get("output_format", self.output_format),
"page_count": poll_result.get("page_count", 0),
"output_format": final_result.get("output_format", self.output_format),
"page_count": final_result.get("page_count", 0),
"processed_with_llm": self.use_llm,
"request_id": request_id or "",
}
images = poll_result.get("images", {})
images = final_result.get("images", {})
if images:
metadata["image_count"] = len(images)
metadata["images"] = json.dumps(list(images.keys()))
+8 -2
View File
@@ -181,7 +181,7 @@ class DoclingLoader:
if lang.strip()
]
endpoint = f"{self.url}/v1alpha/convert/file"
endpoint = f"{self.url}/v1/convert/file"
r = requests.post(endpoint, files=files, data=params)
if r.ok:
@@ -281,10 +281,15 @@ class Loader:
"tiff",
]
):
api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
if not api_base_url or api_base_url.strip() == "":
api_base_url = "https://www.datalab.to/api/v1"
loader = DatalabMarkerLoader(
file_path=file_path,
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
api_base_url=api_base_url,
additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"),
use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False),
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
@@ -295,6 +300,7 @@ class Loader:
disable_image_extraction=self.kwargs.get(
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
),
format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False),
output_format=self.kwargs.get(
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
),
+5 -1
View File
@@ -508,7 +508,11 @@ def get_sources_from_items(
# Note Attached
note = Notes.get_note_by_id(item.get("id"))
if user.role == "admin" or has_access(user.id, "read", note.access_control):
if note and (
user.role == "admin"
or note.user_id == user.id
or has_access(user.id, "read", note.access_control)
):
# User has access to the note
query_result = {
"documents": [[note.data.get("content", {}).get("md", "")]],
@@ -11,6 +11,8 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.config import (
CHROMA_DATA_PATH,
CHROMA_HTTP_HOST,
@@ -144,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [item["metadata"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@@ -164,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [item["metadata"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
@@ -2,6 +2,8 @@ from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -243,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
}
for item in batch
@@ -264,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -3,6 +3,8 @@ from pymilvus import FieldSchema, DataType
import json
import logging
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -311,7 +313,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
}
for item in items
],
@@ -347,7 +349,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
}
for item in items
],
@@ -2,6 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -200,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
}
for item in batch
@@ -222,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
"metadata": stringify_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -0,0 +1,943 @@
"""
Oracle 23ai Vector Database Client - Fixed Version
# .env
VECTOR_DB = "oracle23ai"
## DBCS or oracle 23ai free
ORACLE_DB_USE_WALLET = false
ORACLE_DB_USER = "DEMOUSER"
ORACLE_DB_PASSWORD = "Welcome123456"
ORACLE_DB_DSN = "localhost:1521/FREEPDB1"
## ADW or ATP
# ORACLE_DB_USE_WALLET = true
# ORACLE_DB_USER = "DEMOUSER"
# ORACLE_DB_PASSWORD = "Welcome123456"
# ORACLE_DB_DSN = "medium"
# ORACLE_DB_DSN = "(description= (retry_count=3)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=xx.oraclecloud.com))(connect_data=(service_name=yy.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
# ORACLE_WALLET_DIR = "/home/opc/adb_wallet"
# ORACLE_WALLET_PASSWORD = "Welcome1"
ORACLE_VECTOR_LENGTH = 768
ORACLE_DB_POOL_MIN = 2
ORACLE_DB_POOL_MAX = 10
ORACLE_DB_POOL_INCREMENT = 1
"""
from typing import Optional, List, Dict, Any, Union
from decimal import Decimal
import logging
import os
import threading
import time
import json
import array
import oracledb
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
ORACLE_DB_USE_WALLET,
ORACLE_DB_USER,
ORACLE_DB_PASSWORD,
ORACLE_DB_DSN,
ORACLE_WALLET_DIR,
ORACLE_WALLET_PASSWORD,
ORACLE_VECTOR_LENGTH,
ORACLE_DB_POOL_MIN,
ORACLE_DB_POOL_MAX,
ORACLE_DB_POOL_INCREMENT,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class Oracle23aiClient(VectorDBBase):
"""
Oracle Vector Database Client for vector similarity search using Oracle Database 23ai.
This client provides an interface to store, retrieve, and search vector embeddings
in an Oracle database. It uses connection pooling for efficient database access
and supports vector similarity search operations.
Attributes:
pool: Connection pool for Oracle database connections
"""
def __init__(self) -> None:
"""
Initialize the Oracle23aiClient with a connection pool.
Creates a connection pool with configurable min/max connections, initializes
the database schema if needed, and sets up necessary tables and indexes.
Raises:
ValueError: If required configuration parameters are missing
Exception: If database initialization fails
"""
self.pool = None
try:
# Create the appropriate connection pool based on DB type
if ORACLE_DB_USE_WALLET:
self._create_adb_pool()
else: # DBCS
self._create_dbcs_pool()
dsn = ORACLE_DB_DSN
log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]")
with self.get_connection() as connection:
log.info(f"Connection version: {connection.version}")
self._initialize_database(connection)
log.info("Oracle Vector Search initialization complete.")
except Exception as e:
log.exception(f"Error during Oracle Vector Search initialization: {e}")
raise
def _create_adb_pool(self) -> None:
"""
Create connection pool for Oracle Autonomous Database.
Uses wallet-based authentication.
"""
self.pool = oracledb.create_pool(
user=ORACLE_DB_USER,
password=ORACLE_DB_PASSWORD,
dsn=ORACLE_DB_DSN,
min=ORACLE_DB_POOL_MIN,
max=ORACLE_DB_POOL_MAX,
increment=ORACLE_DB_POOL_INCREMENT,
config_dir=ORACLE_WALLET_DIR,
wallet_location=ORACLE_WALLET_DIR,
wallet_password=ORACLE_WALLET_PASSWORD,
)
log.info("Created ADB connection pool with wallet authentication.")
def _create_dbcs_pool(self) -> None:
"""
Create connection pool for Oracle Database Cloud Service.
Uses basic authentication without wallet.
"""
self.pool = oracledb.create_pool(
user=ORACLE_DB_USER,
password=ORACLE_DB_PASSWORD,
dsn=ORACLE_DB_DSN,
min=ORACLE_DB_POOL_MIN,
max=ORACLE_DB_POOL_MAX,
increment=ORACLE_DB_POOL_INCREMENT,
)
log.info("Created DB connection pool with basic authentication.")
def get_connection(self):
"""
Acquire a connection from the connection pool with retry logic.
Returns:
connection: A database connection with output type handler configured
"""
max_retries = 3
for attempt in range(max_retries):
try:
connection = self.pool.acquire()
connection.outputtypehandler = self._output_type_handler
return connection
except oracledb.DatabaseError as e:
(error_obj,) = e.args
log.exception(
f"Connection attempt {attempt + 1} failed: {error_obj.message}"
)
if attempt < max_retries - 1:
wait_time = 2**attempt
log.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
else:
raise
def start_health_monitor(self, interval_seconds: int = 60):
"""
Start a background thread to periodically check the health of the connection pool.
Args:
interval_seconds (int): Number of seconds between health checks
"""
def _monitor():
while True:
try:
log.info("[HealthCheck] Running periodic DB health check...")
self.ensure_connection()
log.info("[HealthCheck] Connection is healthy.")
except Exception as e:
log.exception(f"[HealthCheck] Connection health check failed: {e}")
time.sleep(interval_seconds)
thread = threading.Thread(target=_monitor, daemon=True)
thread.start()
log.info(f"Started DB health monitor every {interval_seconds} seconds.")
def _reconnect_pool(self):
"""
Attempt to reinitialize the connection pool if it's been closed or broken.
"""
try:
log.info("Attempting to reinitialize the Oracle connection pool...")
# Close existing pool if it exists
if self.pool:
try:
self.pool.close()
except Exception as close_error:
log.warning(f"Error closing existing pool: {close_error}")
# Re-create the appropriate connection pool based on DB type
if ORACLE_DB_USE_WALLET:
self._create_adb_pool()
else: # DBCS
self._create_dbcs_pool()
log.info("Connection pool reinitialized.")
except Exception as e:
log.exception(f"Failed to reinitialize the connection pool: {e}")
raise
def ensure_connection(self):
"""
Ensure the database connection is alive, reconnecting pool if needed.
"""
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM dual")
except Exception as e:
log.exception(
f"Connection check failed: {e}, attempting to reconnect pool..."
)
self._reconnect_pool()
def _output_type_handler(self, cursor, metadata):
"""
Handle Oracle vector type conversion.
Args:
cursor: Oracle database cursor
metadata: Metadata for the column
Returns:
A variable with appropriate conversion for vector types
"""
if metadata.type_code is oracledb.DB_TYPE_VECTOR:
return cursor.var(
metadata.type_code, arraysize=cursor.arraysize, outconverter=list
)
def _initialize_database(self, connection) -> None:
"""
Initialize database schema, tables and indexes.
Creates the document_chunk table and necessary indexes if they don't exist.
Args:
connection: Oracle database connection
Raises:
Exception: If schema initialization fails
"""
with connection.cursor() as cursor:
try:
log.info("Creating Table document_chunk")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE IF NOT EXISTS document_chunk (
id VARCHAR2(255) PRIMARY KEY,
collection_name VARCHAR2(255) NOT NULL,
text CLOB,
vmetadata JSON,
vector vector(*, float32)
)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
log.info("Creating Index document_chunk_collection_name_idx")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
ON document_chunk (collection_name)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
cursor.execute(
"""
BEGIN
EXECUTE IMMEDIATE '
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
ON document_chunk(vector)
ORGANIZATION NEIGHBOR PARTITIONS
DISTANCE COSINE
WITH TARGET ACCURACY 95
PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 100)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -955 THEN
RAISE;
END IF;
END;
"""
)
connection.commit()
log.info("Database initialization completed successfully.")
except Exception as e:
connection.rollback()
log.exception(f"Error during database initialization: {e}")
raise
def check_vector_length(self) -> None:
"""
Check vector length compatibility (placeholder).
This method would check if the configured vector length matches the database schema.
Currently implemented as a placeholder.
"""
pass
def _vector_to_blob(self, vector: List[float]) -> bytes:
"""
Convert a vector to Oracle BLOB format.
Args:
vector (List[float]): The vector to convert
Returns:
bytes: The vector in Oracle BLOB format
"""
return array.array("f", vector)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
"""
Adjust vector to the expected length if needed.
Args:
vector (List[float]): The vector to adjust
Returns:
List[float]: The adjusted vector
"""
return vector
def _decimal_handler(self, obj):
"""
Handle Decimal objects for JSON serialization.
Args:
obj: Object to serialize
Returns:
float: Converted decimal value
Raises:
TypeError: If object is not JSON serializable
"""
if isinstance(obj, Decimal):
return float(obj)
raise TypeError(f"{obj} is not JSON serializable")
def _metadata_to_json(self, metadata: Dict) -> str:
"""
Convert metadata dictionary to JSON string.
Args:
metadata (Dict): Metadata dictionary
Returns:
str: JSON representation of metadata
"""
return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}"
def _json_to_metadata(self, json_str: str) -> Dict:
"""
Convert JSON string to metadata dictionary.
Args:
json_str (str): JSON string
Returns:
Dict: Metadata dictionary
"""
return json.loads(json_str) if json_str else {}
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert vector items into the database.
Args:
collection_name (str): Name of the collection
items (List[VectorItem]): List of vector items to insert
Raises:
Exception: If insertion fails
Example:
>>> client = Oracle23aiClient()
>>> items = [
... {"id": "1", "text": "Sample text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
... {"id": "2", "text": "Another text", "vector": [0.3, 0.4, ...], "metadata": {"source": "doc2"}}
... ]
>>> client.insert("my_collection", items)
"""
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
with connection.cursor() as cursor:
for item in items:
vector_blob = self._vector_to_blob(item["vector"])
metadata_json = self._metadata_to_json(item["metadata"])
cursor.execute(
"""
INSERT INTO document_chunk
(id, collection_name, text, vmetadata, vector)
VALUES (:id, :collection_name, :text, :metadata, :vector)
""",
{
"id": item["id"],
"collection_name": collection_name,
"text": item["text"],
"metadata": metadata_json,
"vector": vector_blob,
},
)
connection.commit()
log.info(
f"Successfully inserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e:
connection.rollback()
log.exception(f"Error during insert: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Update or insert vector items into the database.
If an item with the same ID exists, it will be updated;
otherwise, it will be inserted.
Args:
collection_name (str): Name of the collection
items (List[VectorItem]): List of vector items to upsert
Raises:
Exception: If upsert operation fails
Example:
>>> client = Oracle23aiClient()
>>> items = [
... {"id": "1", "text": "Updated text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
... {"id": "3", "text": "New item", "vector": [0.5, 0.6, ...], "metadata": {"source": "doc3"}}
... ]
>>> client.upsert("my_collection", items)
"""
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
with connection.cursor() as cursor:
for item in items:
vector_blob = self._vector_to_blob(item["vector"])
metadata_json = self._metadata_to_json(item["metadata"])
cursor.execute(
"""
MERGE INTO document_chunk d
USING (SELECT :merge_id as id FROM dual) s
ON (d.id = s.id)
WHEN MATCHED THEN
UPDATE SET
collection_name = :upd_collection_name,
text = :upd_text,
vmetadata = :upd_metadata,
vector = :upd_vector
WHEN NOT MATCHED THEN
INSERT (id, collection_name, text, vmetadata, vector)
VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector)
""",
{
"merge_id": item["id"],
"upd_collection_name": collection_name,
"upd_text": item["text"],
"upd_metadata": metadata_json,
"upd_vector": vector_blob,
"ins_id": item["id"],
"ins_collection_name": collection_name,
"ins_text": item["text"],
"ins_metadata": metadata_json,
"ins_vector": vector_blob,
},
)
connection.commit()
log.info(
f"Successfully upserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e:
connection.rollback()
log.exception(f"Error during upsert: {e}")
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
"""
Search for similar vectors in the database.
Performs vector similarity search using cosine distance.
Args:
collection_name (str): Name of the collection to search
vectors (List[List[Union[float, int]]]): Query vectors to find similar items for
limit (int): Maximum number of results to return per query
Returns:
Optional[SearchResult]: Search results containing ids, distances, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> query_vector = [0.1, 0.2, 0.3, ...] # Must match VECTOR_LENGTH
>>> results = client.search("my_collection", [query_vector], limit=5)
>>> if results:
... log.info(f"Found {len(results.ids[0])} matches")
... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
... log.info(f"Match {i+1}: id={id}, distance={dist}")
"""
log.info(
f"Searching items from collection '{collection_name}' with limit {limit}."
)
try:
if not vectors:
log.warning("No vectors provided for search.")
return None
num_queries = len(vectors)
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
with self.get_connection() as connection:
with connection.cursor() as cursor:
for qid, vector in enumerate(vectors):
vector_blob = self._vector_to_blob(vector)
cursor.execute(
"""
SELECT dc.id, dc.text,
JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
FROM document_chunk dc
WHERE dc.collection_name = :collection_name
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
FETCH APPROX FIRST :limit ROWS ONLY
""",
{
"query_vector": vector_blob,
"collection_name": collection_name,
"limit": limit,
},
)
results = cursor.fetchall()
for row in results:
ids[qid].append(row[0])
documents[qid].append(
row[1].read()
if isinstance(row[1], oracledb.LOB)
else str(row[1])
)
# 🔧 FIXED: Parse JSON metadata properly
metadata_str = (
row[2].read()
if isinstance(row[2], oracledb.LOB)
else row[2]
)
metadatas[qid].append(self._json_to_metadata(metadata_str))
distances[qid].append(float(row[3]))
log.info(
f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results."
)
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
log.exception(f"Error during search: {e}")
return None
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
"""
Query items based on metadata filters.
Retrieves items that match specified metadata criteria.
Args:
collection_name (str): Name of the collection to query
filter (Dict[str, Any]): Metadata filters to apply
limit (Optional[int]): Maximum number of results to return
Returns:
Optional[GetResult]: Query results containing ids, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> filter = {"source": "doc1", "category": "finance"}
>>> results = client.query("my_collection", filter, limit=20)
>>> if results:
... print(f"Found {len(results.ids[0])} matching documents")
"""
log.info(f"Querying items from collection '{collection_name}' with filters.")
try:
limit = limit or 100
query = """
SELECT id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
FROM document_chunk
WHERE collection_name = :collection_name
"""
params = {"collection_name": collection_name}
for i, (key, value) in enumerate(filter.items()):
param_name = f"value_{i}"
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
params[param_name] = str(value)
query += " FETCH FIRST :limit ROWS ONLY"
params["limit"] = limit
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(query, params)
results = cursor.fetchall()
if not results:
log.info("No results found for query.")
return None
ids = [[row[0] for row in results]]
documents = [
[
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
for row in results
]
]
# 🔧 FIXED: Parse JSON metadata properly
metadatas = [
[
self._json_to_metadata(
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
)
for row in results
]
]
log.info(f"Query completed. Found {len(results)} results.")
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
log.exception(f"Error during query: {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
"""
Get all items in a collection.
Retrieves items from a specified collection up to the limit.
Args:
collection_name (str): Name of the collection to retrieve
limit (Optional[int]): Maximum number of items to retrieve
Returns:
Optional[GetResult]: Result containing ids, documents, and metadata
Example:
>>> client = Oracle23aiClient()
>>> results = client.get("my_collection", limit=50)
>>> if results:
... print(f"Retrieved {len(results.ids[0])} documents from collection")
"""
log.info(
f"Getting items from collection '{collection_name}' with limit {limit}."
)
try:
limit = limit or 1000
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT /*+ MONITOR */ id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
FROM document_chunk
WHERE collection_name = :collection_name
FETCH FIRST :limit ROWS ONLY
""",
{"collection_name": collection_name, "limit": limit},
)
results = cursor.fetchall()
if not results:
log.info("No results found.")
return None
ids = [[row[0] for row in results]]
documents = [
[
row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
for row in results
]
]
# 🔧 FIXED: Parse JSON metadata properly
metadatas = [
[
self._json_to_metadata(
row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
)
for row in results
]
]
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
log.exception(f"Error during get: {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
"""
Delete items from the database.
Deletes items from a collection based on IDs or metadata filters.
Args:
collection_name (str): Name of the collection to delete from
ids (Optional[List[str]]): Specific item IDs to delete
filter (Optional[Dict[str, Any]]): Metadata filters for deletion
Raises:
Exception: If deletion fails
Example:
>>> client = Oracle23aiClient()
>>> # Delete specific items by ID
>>> client.delete("my_collection", ids=["1", "3", "5"])
>>> # Or delete by metadata filter
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
"""
log.info(f"Deleting items from collection '{collection_name}'.")
try:
query = (
"DELETE FROM document_chunk WHERE collection_name = :collection_name"
)
params = {"collection_name": collection_name}
if ids:
# 🔧 FIXED: Use proper parameterized query to prevent SQL injection
placeholders = ",".join([f":id_{i}" for i in range(len(ids))])
query += f" AND id IN ({placeholders})"
for i, id_val in enumerate(ids):
params[f"id_{i}"] = id_val
if filter:
for i, (key, value) in enumerate(filter.items()):
param_name = f"value_{i}"
query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
params[param_name] = str(value)
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(query, params)
deleted = cursor.rowcount
connection.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e:
log.exception(f"Error during delete: {e}")
raise
def reset(self) -> None:
"""
Reset the database by deleting all items.
Deletes all items from the document_chunk table.
Raises:
Exception: If reset fails
Example:
>>> client = Oracle23aiClient()
>>> client.reset() # Warning: Removes all data!
"""
log.info("Resetting database - deleting all items.")
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM document_chunk")
deleted = cursor.rowcount
connection.commit()
log.info(
f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
)
except Exception as e:
log.exception(f"Error during reset: {e}")
raise
def close(self) -> None:
"""
Close the database connection pool.
Properly closes the connection pool and releases all resources.
Example:
>>> client = Oracle23aiClient()
>>> # After finishing all operations
>>> client.close()
"""
try:
if hasattr(self, "pool") and self.pool:
self.pool.close()
log.info("Oracle Vector Search connection pool closed.")
except Exception as e:
log.exception(f"Error closing connection pool: {e}")
def has_collection(self, collection_name: str) -> bool:
"""
Check if a collection exists.
Args:
collection_name (str): Name of the collection to check
Returns:
bool: True if the collection exists, False otherwise
Example:
>>> client = Oracle23aiClient()
>>> if client.has_collection("my_collection"):
... print("Collection exists!")
... else:
... print("Collection does not exist.")
"""
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT COUNT(*)
FROM document_chunk
WHERE collection_name = :collection_name
FETCH FIRST 1 ROWS ONLY
""",
{"collection_name": collection_name},
)
count = cursor.fetchone()[0]
return count > 0
except Exception as e:
log.exception(f"Error checking collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
"""
Delete an entire collection.
Removes all items belonging to the specified collection.
Args:
collection_name (str): Name of the collection to delete
Example:
>>> client = Oracle23aiClient()
>>> client.delete_collection("obsolete_collection")
"""
log.info(f"Deleting collection '{collection_name}'.")
try:
with self.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
DELETE FROM document_chunk
WHERE collection_name = :collection_name
""",
{"collection_name": collection_name},
)
deleted = cursor.rowcount
connection.commit()
log.info(
f"Collection '{collection_name}' deleted. Removed {deleted} items."
)
except Exception as e:
log.exception(f"Error deleting collection '{collection_name}': {e}")
raise
@@ -26,6 +26,8 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -201,6 +203,8 @@ class PgvectorClient(VectorDBBase):
for item in items:
vector = self.adjust_vector_length(item["vector"])
# Use raw SQL for BYTEA/pgcrypto
# Ensure metadata is converted to its JSON text representation
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -209,7 +213,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
pgp_sym_encrypt(:metadata::text, :key)
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
@@ -219,7 +223,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
"metadata": json.dumps(item["metadata"]),
"metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -235,7 +239,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=item["metadata"],
vmetadata=stringify_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@@ -253,6 +257,7 @@ class PgvectorClient(VectorDBBase):
if PGVECTOR_PGCRYPTO:
for item in items:
vector = self.adjust_vector_length(item["vector"])
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -261,7 +266,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
pgp_sym_encrypt(:metadata::text, :key)
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO UPDATE SET
vector = EXCLUDED.vector,
@@ -275,7 +280,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
"metadata": json.dumps(item["metadata"]),
"metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -292,7 +297,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = item["metadata"]
existing.vmetadata = stringify_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@@ -302,7 +307,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=item["metadata"],
vmetadata=stringify_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
@@ -416,10 +421,12 @@ class PgvectorClient(VectorDBBase):
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
self.session.rollback() # read-only transaction
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
self.session.rollback()
log.exception(f"Error during search: {e}")
return None
@@ -472,12 +479,14 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback() # read-only transaction
return GetResult(
ids=ids,
documents=documents,
metadatas=metadatas,
)
except Exception as e:
self.session.rollback()
log.exception(f"Error during query: {e}")
return None
@@ -518,8 +527,10 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback() # read-only transaction
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Error during get: {e}")
return None
@@ -587,8 +598,10 @@ class PgvectorClient(VectorDBBase):
.first()
is not None
)
self.session.rollback() # read-only transaction
return exists
except Exception as e:
self.session.rollback()
log.exception(f"Error checking collection existence: {e}")
return False
@@ -32,6 +32,8 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import stringify_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@@ -183,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
"metadata": metadata,
"metadata": stringify_metadata(metadata),
}
points.append(point)
return points
@@ -19,6 +19,8 @@ from open_webui.config import (
QDRANT_GRPC_PORT,
QDRANT_PREFER_GRPC,
QDRANT_COLLECTION_PREFIX,
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -36,6 +38,8 @@ class QdrantClient(VectorDBBase):
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
self.client = None
@@ -53,9 +57,14 @@ class QdrantClient(VectorDBBase):
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
else:
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
self.client = Qclient(
url=self.QDRANT_URI,
api_key=self.QDRANT_API_KEY,
timeout=QDRANT_TIMEOUT,
)
def _result_to_get_result(self, points) -> GetResult:
ids = []
@@ -85,6 +94,9 @@ class QdrantClient(VectorDBBase):
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
hnsw_config=models.HnswConfigDiff(
m=self.QDRANT_HNSW_M,
),
)
# Create payload indexes for efficient filtering
@@ -171,23 +183,23 @@ class QdrantClient(VectorDBBase):
)
)
points = self.client.query_points(
points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
query_filter=models.Filter(should=field_conditions),
scroll_filter=models.Filter(should=field_conditions),
limit=limit,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
except Exception as e:
log.exception(f"Error querying a collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
# Get all the items in the collection.
points = self.client.query_points(
points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
limit=NO_LIMIT, # otherwise qdrant would set limit to 10!
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
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.
@@ -10,6 +10,8 @@ from open_webui.config import (
QDRANT_PREFER_GRPC,
QDRANT_URI,
QDRANT_COLLECTION_PREFIX,
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
@@ -51,6 +53,8 @@ class QdrantClient(VectorDBBase):
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
raise ValueError(
@@ -69,9 +73,14 @@ class QdrantClient(VectorDBBase):
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
if self.PREFER_GRPC
else Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
else Qclient(
url=self.QDRANT_URI,
api_key=self.QDRANT_API_KEY,
timeout=self.QDRANT_TIMEOUT,
)
)
# Main collection types for multi-tenancy
@@ -133,6 +142,12 @@ class QdrantClient(VectorDBBase):
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
# Disable global index building due to multitenancy
# For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
hnsw_config=models.HnswConfigDiff(
payload_m=self.QDRANT_HNSW_M,
m=0,
),
)
log.info(
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
@@ -278,12 +293,12 @@ class QdrantClient(VectorDBBase):
tenant_filter = _tenant_filter(tenant_id)
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
points = self.client.query_points(
points = self.client.scroll(
collection_name=mt_collection,
query_filter=combined_filter,
scroll_filter=combined_filter,
limit=limit,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
def get(self, collection_name: str) -> Optional[GetResult]:
"""
@@ -296,12 +311,12 @@ class QdrantClient(VectorDBBase):
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
return None
tenant_filter = _tenant_filter(tenant_id)
points = self.client.query_points(
points = self.client.scroll(
collection_name=mt_collection,
query_filter=models.Filter(must=[tenant_filter]),
scroll_filter=models.Filter(must=[tenant_filter]),
limit=NO_LIMIT,
)
return self._result_to_get_result(points.points)
return self._result_to_get_result(points[0])
def upsert(self, collection_name: str, items: List[VectorItem]):
"""
@@ -1,3 +1,4 @@
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -183,6 +184,9 @@ class S3VectorClient(VectorDBBase):
# Add the text field to metadata so it's available for retrieval
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
@@ -247,6 +251,9 @@ class S3VectorClient(VectorDBBase):
# Add the text field to metadata so it's available for retrieval
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
@@ -52,6 +52,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
return ChromaClient()
case VectorType.ORACLE23AI:
from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient
return Oracle23aiClient()
case _:
raise ValueError(f"Unsupported vector type: {vector_type}")
@@ -9,4 +9,5 @@ class VectorType(StrEnum):
ELASTICSEARCH = "elasticsearch"
OPENSEARCH = "opensearch"
PGVECTOR = "pgvector"
ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector"
@@ -0,0 +1,14 @@
from datetime import datetime
def stringify_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
for key, value in metadata.items():
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
return metadata
+5 -1
View File
@@ -561,7 +561,11 @@ def transcription_handler(request, file_path, metadata):
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
language=metadata.get("language") or WHISPER_LANGUAGE,
language=(
metadata.get("language", None)
if WHISPER_LANGUAGE == ""
else WHISPER_LANGUAGE
),
)
log.info(
"Detected language '%s' with probability %f"
+7 -11
View File
@@ -351,11 +351,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
user = Users.get_user_by_email(email)
if not user:
try:
user_count = Users.get_num_users()
role = (
"admin"
if user_count == 0
if not Users.has_users()
else request.app.state.config.DEFAULT_USER_ROLE
)
@@ -489,7 +487,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
if Users.get_user_by_email(admin_email.lower()):
user = Auths.authenticate_user(admin_email.lower(), admin_password)
else:
if Users.get_num_users() != 0:
if Users.has_users():
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
@@ -556,6 +554,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm):
has_users = Users.has_users()
if WEBUI_AUTH:
if (
@@ -566,12 +565,11 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
if Users.get_num_users() != 0:
if has_users:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
user_count = Users.get_num_users()
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
@@ -581,9 +579,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
role = (
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(form_data.password.encode("utf-8")) > 72:
@@ -644,7 +640,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
user.id, request.app.state.config.USER_PERMISSIONS
)
if user_count == 0:
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
@@ -673,7 +669,7 @@ async def signout(request: Request, response: Response):
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = request.cookies.get("oauth_id_token")
if oauth_id_token:
if oauth_id_token and OPENID_PROVIDER_URL.value:
try:
async with ClientSession(trust_env=True) as session:
async with session.get(OPENID_PROVIDER_URL.value) as resp:
+18 -14
View File
@@ -434,13 +434,6 @@ async def update_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -452,6 +445,15 @@ async def update_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
@@ -641,13 +643,6 @@ async def delete_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -659,6 +654,15 @@ async def delete_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.delete_message_by_id(message_id)
await sio.emit(
+23 -2
View File
@@ -617,7 +617,18 @@ async def clone_chat_by_id(
"title": form_data.title if form_data.title else f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
chat = Chats.import_chat(
user.id,
ChatImportForm(
**{
"chat": updated_chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
}
),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
@@ -646,7 +657,17 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
"title": f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
chat = Chats.import_chat(
user.id,
ChatImportForm(
**{
"chat": updated_chat,
"meta": chat.meta,
"pinned": chat.pinned,
"folder_id": chat.folder_id,
}
),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
+6 -2
View File
@@ -7,7 +7,11 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import BannerModel
from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data
from open_webui.utils.tools import (
get_tool_server_data,
get_tool_servers_data,
get_tool_server_url,
)
router = APIRouter()
@@ -135,7 +139,7 @@ async def verify_tool_servers_config(
elif form_data.auth_type == "session":
token = request.state.token.credentials
url = f"{form_data.url}/{form_data.path}"
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
except Exception as e:
raise HTTPException(
+10 -4
View File
@@ -129,7 +129,10 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
if user.role == "admin":
feedback = Feedbacks.get_feedback_by_id(id=id)
else:
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
if not feedback:
raise HTTPException(
@@ -143,9 +146,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
):
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
)
if user.role == "admin":
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
else:
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
)
if not feedback:
raise HTTPException(
+5 -5
View File
@@ -244,11 +244,11 @@ async def delete_folder_by_id(
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
try:
result = Folders.delete_folder_by_id_and_user_id(id, user.id)
if result:
return result
else:
raise Exception("Error deleting folder")
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
for folder_id in folder_ids:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
return True
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
+17 -3
View File
@@ -131,15 +131,29 @@ async def load_function_from_url(
############################
class SyncFunctionsForm(FunctionForm):
class SyncFunctionsForm(BaseModel):
functions: list[FunctionModel] = []
@router.post("/sync", response_model=Optional[FunctionModel])
@router.post("/sync", response_model=list[FunctionModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
return Functions.sync_functions(user.id, form_data.functions)
try:
for function in form_data.functions:
function.content = replace_imports(function.content)
function_module, function_type, frontmatter = load_function_module_by_id(
function.id,
content=function.content,
)
return Functions.sync_functions(user.id, form_data.functions)
except Exception as e:
log.exception(f"Failed to load a function: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
############################
+3 -2
View File
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
from open_webui.models.models import Models, ModelForm
@@ -42,7 +43,7 @@ router = APIRouter()
async def get_knowledge(user=Depends(get_verified_user)):
knowledge_bases = []
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
@@ -90,7 +91,7 @@ async def get_knowledge(user=Depends(get_verified_user)):
async def get_knowledge_list(user=Depends(get_verified_user)):
knowledge_bases = []
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
+4
View File
@@ -82,6 +82,10 @@ class QueryMemoryForm(BaseModel):
async def query_memory(
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
):
memories = Memories.get_memories_by_user_id(user.id)
if not memories:
raise HTTPException(status_code=404, detail="No memories found for user")
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
+31 -3
View File
@@ -7,13 +7,15 @@ from open_webui.models.models import (
ModelUserResponse,
Models,
)
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
router = APIRouter()
@@ -25,7 +27,7 @@ router = APIRouter()
@router.get("/", response_model=list[ModelUserResponse])
async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
return Models.get_models()
else:
return Models.get_models_by_user_id(user.id)
@@ -78,6 +80,32 @@ async def create_new_model(
)
############################
# ExportModels
############################
@router.get("/export", response_model=list[ModelModel])
async def export_models(user=Depends(get_admin_user)):
return Models.get_models()
############################
# SyncModels
############################
class SyncModelsForm(BaseModel):
models: list[ModelModel] = []
@router.post("/sync", response_model=list[ModelModel])
async def sync_models(
request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user)
):
return Models.sync_models(user.id, form_data.models)
###########################
# GetModelById
###########################
@@ -102,7 +130,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
############################
# ToggelModelById
# ToggleModelById
############################
+86 -63
View File
@@ -2,17 +2,20 @@ import asyncio
import hashlib
import json
import logging
from pathlib import Path
from typing import Literal, Optional, overload
from typing import Optional
import aiohttp
from aiocache import cached
import requests
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi import Depends, HTTPException, Request, APIRouter
from fastapi.responses import (
FileResponse,
StreamingResponse,
JSONResponse,
PlainTextResponse,
)
from pydantic import BaseModel
from starlette.background import BackgroundTask
@@ -31,7 +34,7 @@ from open_webui.env import (
from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENV, SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.payload import (
@@ -95,12 +98,12 @@ async def cleanup_response(
await session.close()
def openai_o_series_handler(payload):
def openai_reasoning_model_handler(payload):
"""
Handle "o" series specific parameters
Handle reasoning model specific parameters
"""
if "max_tokens" in payload:
# Convert "max_tokens" to "max_completion_tokens" for all o-series models
# Convert "max_tokens" to "max_completion_tokens" for all reasoning models
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
@@ -362,7 +365,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
response if isinstance(response, list) else response.get("data", [])
):
if prefix_id:
model["id"] = f"{prefix_id}.{model['id']}"
model["id"] = (
f"{prefix_id}.{model.get('id', model.get('name', ''))}"
)
if tags:
model["tags"] = tags
@@ -593,15 +598,21 @@ async def verify_connection(
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
if isinstance(response_data, (dict, list)):
return JSONResponse(
status_code=r.status, content=response_data
)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
response_data = await r.json()
return response_data
else:
headers["Authorization"] = f"Bearer {key}"
@@ -611,15 +622,21 @@ async def verify_connection(
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
if isinstance(response_data, (dict, list)):
return JSONResponse(
status_code=r.status, content=response_data
)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
response_data = await r.json()
return response_data
except aiohttp.ClientError as e:
@@ -630,8 +647,9 @@ async def verify_connection(
)
except Exception as e:
log.exception(f"Unexpected error: {e}")
error_detail = f"Unexpected error: {str(e)}"
raise HTTPException(status_code=500, detail=error_detail)
raise HTTPException(
status_code=500, detail="Open WebUI: Server Connection Error"
)
def get_azure_allowed_params(api_version: str) -> set[str]:
@@ -787,10 +805,12 @@ async def generate_chat_completion(
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Check if model is from "o" series
is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4"))
if is_o_series:
payload = openai_o_series_handler(payload)
# Check if model is a reasoning model that needs special handling
is_reasoning_model = (
payload["model"].lower().startswith(("o1", "o3", "o4", "gpt-5"))
)
if is_reasoning_model:
payload = openai_reasoning_model_handler(payload)
elif "api.openai.com" not in url:
# Remove "max_completion_tokens" from the payload for backward compatibility
if "max_completion_tokens" in payload:
@@ -881,21 +901,19 @@ async def generate_chat_completion(
log.error(e)
response = await r.text()
r.raise_for_status()
if r.status >= 400:
if isinstance(response, (dict, list)):
return JSONResponse(status_code=r.status, content=response)
else:
return PlainTextResponse(status_code=r.status, content=response)
return response
except Exception as e:
log.exception(e)
detail = None
if isinstance(response, dict):
if "error" in response:
detail = f"{response['error']['message'] if 'message' in response['error'] else response['error']}"
elif isinstance(response, str):
detail = response
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
@@ -949,7 +967,7 @@ async def embeddings(request: Request, form_data: dict, user):
),
},
)
r.raise_for_status()
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
@@ -961,21 +979,25 @@ async def embeddings(request: Request, form_data: dict, user):
),
)
else:
response_data = await r.json()
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status >= 400:
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
return response_data
except Exception as e:
log.exception(e)
detail = None
if r is not None:
try:
res = await r.json()
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
@@ -1041,7 +1063,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
@@ -1055,24 +1076,26 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
),
)
else:
response_data = await r.json()
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status >= 400:
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
return response_data
except Exception as e:
log.exception(e)
detail = None
if r is not None:
try:
res = await r.json()
log.error(res)
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
detail=detail if detail else "Open WebUI: Server Connection Error",
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
+4 -3
View File
@@ -1,4 +1,5 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.models.prompts import (
PromptForm,
@@ -7,9 +8,9 @@ from open_webui.models.prompts import (
Prompts,
)
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
router = APIRouter()
@@ -20,7 +21,7 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
@@ -30,7 +31,7 @@ async def get_prompts(user=Depends(get_verified_user)):
@router.get("/list", response_model=list[PromptUserResponse])
async def get_prompt_list(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
+31 -27
View File
@@ -401,12 +401,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
"DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
"DATALAB_MARKER_FORMAT_LINES": request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
"DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM,
"DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
"EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
@@ -566,12 +568,14 @@ class ConfigForm(BaseModel):
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None
DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_LANGS: Optional[str] = None
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None
DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None
DATALAB_MARKER_FORCE_OCR: Optional[bool] = None
DATALAB_MARKER_PAGINATE: Optional[bool] = None
DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None
DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None
DATALAB_MARKER_USE_LLM: Optional[bool] = None
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
@@ -683,10 +687,15 @@ async def update_rag_config(
if form_data.DATALAB_MARKER_API_KEY is not None
else request.app.state.config.DATALAB_MARKER_API_KEY
)
request.app.state.config.DATALAB_MARKER_LANGS = (
form_data.DATALAB_MARKER_LANGS
if form_data.DATALAB_MARKER_LANGS is not None
else request.app.state.config.DATALAB_MARKER_LANGS
request.app.state.config.DATALAB_MARKER_API_BASE_URL = (
form_data.DATALAB_MARKER_API_BASE_URL
if form_data.DATALAB_MARKER_API_BASE_URL is not None
else request.app.state.config.DATALAB_MARKER_API_BASE_URL
)
request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = (
form_data.DATALAB_MARKER_ADDITIONAL_CONFIG
if form_data.DATALAB_MARKER_ADDITIONAL_CONFIG is not None
else request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG
)
request.app.state.config.DATALAB_MARKER_SKIP_CACHE = (
form_data.DATALAB_MARKER_SKIP_CACHE
@@ -713,6 +722,11 @@ async def update_rag_config(
if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None
else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
request.app.state.config.DATALAB_MARKER_FORMAT_LINES = (
form_data.DATALAB_MARKER_FORMAT_LINES
if form_data.DATALAB_MARKER_FORMAT_LINES is not None
else request.app.state.config.DATALAB_MARKER_FORMAT_LINES
)
request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = (
form_data.DATALAB_MARKER_OUTPUT_FORMAT
if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None
@@ -1006,7 +1020,8 @@ async def update_rag_config(
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
"DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
"DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR,
"DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE,
@@ -1229,27 +1244,14 @@ def save_docs_to_vector_db(
{
**doc.metadata,
**(metadata if metadata else {}),
"embedding_config": json.dumps(
{
"engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
"model": request.app.state.config.RAG_EMBEDDING_MODEL,
}
),
"embedding_config": {
"engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
"model": request.app.state.config.RAG_EMBEDDING_MODEL,
},
}
for doc in docs
]
# ChromaDB does not like datetime formats
# for meta-data so convert them to string.
for metadata in metadatas:
for key, value in metadata.items():
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name):
log.info(f"collection {collection_name} already exists")
@@ -1406,12 +1408,14 @@ def process_file(
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_LANGS=request.app.state.config.DATALAB_MARKER_LANGS,
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
@@ -1785,7 +1789,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.SERPLY_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No SERPLY_API_KEY found in environment variables")
@@ -1961,7 +1965,7 @@ async def process_web_search(
},
)
for result in search_results
if hasattr(result, "snippet")
if hasattr(result, "snippet") and result.snippet is not None
]
else:
loader = get_web_loader(
+926
View File
@@ -0,0 +1,926 @@
"""
Experimental SCIM 2.0 Implementation for Open WebUI
Provides System for Cross-domain Identity Management endpoints for users and groups
NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change.
"""
import logging
import uuid
import time
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, ConfigDict
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups, GroupModel
from open_webui.utils.auth import (
get_admin_user,
get_current_user,
decode_token,
get_verified_user,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
# SCIM 2.0 Schema URIs
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
# SCIM Resource Types
SCIM_RESOURCE_TYPE_USER = "User"
SCIM_RESOURCE_TYPE_GROUP = "Group"
def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
"""Create a SCIM-compliant error response"""
error_body = {
"schemas": [SCIM_ERROR_SCHEMA],
"status": str(status_code),
"detail": detail,
}
if scim_type:
error_body["scimType"] = scim_type
elif status_code == 404:
error_body["scimType"] = "invalidValue"
elif status_code == 409:
error_body["scimType"] = "uniqueness"
elif status_code == 400:
error_body["scimType"] = "invalidSyntax"
return JSONResponse(status_code=status_code, content=error_body)
class SCIMError(BaseModel):
"""SCIM Error Response"""
schemas: List[str] = [SCIM_ERROR_SCHEMA]
status: str
scimType: Optional[str] = None
detail: Optional[str] = None
class SCIMMeta(BaseModel):
"""SCIM Resource Metadata"""
resourceType: str
created: str
lastModified: str
location: Optional[str] = None
version: Optional[str] = None
class SCIMName(BaseModel):
"""SCIM User Name"""
formatted: Optional[str] = None
familyName: Optional[str] = None
givenName: Optional[str] = None
middleName: Optional[str] = None
honorificPrefix: Optional[str] = None
honorificSuffix: Optional[str] = None
class SCIMEmail(BaseModel):
"""SCIM Email"""
value: str
type: Optional[str] = "work"
primary: bool = True
display: Optional[str] = None
class SCIMPhoto(BaseModel):
"""SCIM Photo"""
value: str
type: Optional[str] = "photo"
primary: bool = True
display: Optional[str] = None
class SCIMGroupMember(BaseModel):
"""SCIM Group Member"""
value: str # User ID
ref: Optional[str] = Field(None, alias="$ref")
type: Optional[str] = "User"
display: Optional[str] = None
class SCIMUser(BaseModel):
"""SCIM User Resource"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
id: str
externalId: Optional[str] = None
userName: str
name: Optional[SCIMName] = None
displayName: str
emails: List[SCIMEmail]
active: bool = True
photos: Optional[List[SCIMPhoto]] = None
groups: Optional[List[Dict[str, str]]] = None
meta: SCIMMeta
class SCIMUserCreateRequest(BaseModel):
"""SCIM User Create Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
externalId: Optional[str] = None
userName: str
name: Optional[SCIMName] = None
displayName: str
emails: List[SCIMEmail]
active: bool = True
password: Optional[str] = None
photos: Optional[List[SCIMPhoto]] = None
class SCIMUserUpdateRequest(BaseModel):
"""SCIM User Update Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_USER_SCHEMA]
id: Optional[str] = None
externalId: Optional[str] = None
userName: Optional[str] = None
name: Optional[SCIMName] = None
displayName: Optional[str] = None
emails: Optional[List[SCIMEmail]] = None
active: Optional[bool] = None
photos: Optional[List[SCIMPhoto]] = None
class SCIMGroup(BaseModel):
"""SCIM Group Resource"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
id: str
displayName: str
members: Optional[List[SCIMGroupMember]] = []
meta: SCIMMeta
class SCIMGroupCreateRequest(BaseModel):
"""SCIM Group Create Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
displayName: str
members: Optional[List[SCIMGroupMember]] = []
class SCIMGroupUpdateRequest(BaseModel):
"""SCIM Group Update Request"""
model_config = ConfigDict(populate_by_name=True)
schemas: List[str] = [SCIM_GROUP_SCHEMA]
displayName: Optional[str] = None
members: Optional[List[SCIMGroupMember]] = None
class SCIMListResponse(BaseModel):
"""SCIM List Response"""
schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
totalResults: int
itemsPerPage: int
startIndex: int
Resources: List[Any]
class SCIMPatchOperation(BaseModel):
"""SCIM Patch Operation"""
op: str # "add", "replace", "remove"
path: Optional[str] = None
value: Optional[Any] = None
class SCIMPatchRequest(BaseModel):
"""SCIM Patch Request"""
schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
Operations: List[SCIMPatchOperation]
def get_scim_auth(
request: Request, authorization: Optional[str] = Header(None)
) -> bool:
"""
Verify SCIM authentication
Checks for SCIM-specific bearer token configured in the system
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
parts = authorization.split()
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization format. Expected: Bearer <token>",
)
scheme, token = parts
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
)
# Check if SCIM is enabled
scim_enabled = getattr(request.app.state, "SCIM_ENABLED", False)
log.info(
f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}"
)
# Handle both PersistentConfig and direct value
if hasattr(scim_enabled, "value"):
scim_enabled = scim_enabled.value
log.info(f"SCIM enabled status after conversion: {scim_enabled}")
if not scim_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SCIM is not enabled",
)
# Verify the SCIM token
scim_token = getattr(request.app.state, "SCIM_TOKEN", None)
# Handle both PersistentConfig and direct value
if hasattr(scim_token, "value"):
scim_token = scim_token.value
log.debug(f"SCIM token configured: {bool(scim_token)}")
if not scim_token or token != scim_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid SCIM token",
)
return True
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
log.error(f"SCIM authentication error: {e}")
import traceback
log.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
)
def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
"""Convert internal User model to SCIM User"""
# Parse display name into name components
name_parts = user.name.split(" ", 1) if user.name else ["", ""]
given_name = name_parts[0] if name_parts else ""
family_name = name_parts[1] if len(name_parts) > 1 else ""
# Get user's groups
user_groups = Groups.get_groups_by_member_id(user.id)
groups = [
{
"value": group.id,
"display": group.name,
"$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
"type": "direct",
}
for group in user_groups
]
return SCIMUser(
id=user.id,
userName=user.email,
name=SCIMName(
formatted=user.name,
givenName=given_name,
familyName=family_name,
),
displayName=user.name,
emails=[SCIMEmail(value=user.email)],
active=user.role != "pending",
photos=(
[SCIMPhoto(value=user.profile_image_url)]
if user.profile_image_url
else None
),
groups=groups if groups else None,
meta=SCIMMeta(
resourceType=SCIM_RESOURCE_TYPE_USER,
created=datetime.fromtimestamp(
user.created_at, tz=timezone.utc
).isoformat(),
lastModified=datetime.fromtimestamp(
user.updated_at, tz=timezone.utc
).isoformat(),
location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
),
)
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
"""Convert internal Group model to SCIM Group"""
members = []
for user_id in group.user_ids:
user = Users.get_user_by_id(user_id)
if user:
members.append(
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
)
return SCIMGroup(
id=group.id,
displayName=group.name,
members=members,
meta=SCIMMeta(
resourceType=SCIM_RESOURCE_TYPE_GROUP,
created=datetime.fromtimestamp(
group.created_at, tz=timezone.utc
).isoformat(),
lastModified=datetime.fromtimestamp(
group.updated_at, tz=timezone.utc
).isoformat(),
location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
),
)
# SCIM Service Provider Config
@router.get("/ServiceProviderConfig")
async def get_service_provider_config():
"""Get SCIM Service Provider Configuration"""
return {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": {"supported": True},
"bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576},
"filter": {"supported": True, "maxResults": 200},
"changePassword": {"supported": False},
"sort": {"supported": False},
"etag": {"supported": False},
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Authentication using OAuth 2.0 Bearer Token",
}
],
}
# SCIM Resource Types
@router.get("/ResourceTypes")
async def get_resource_types(request: Request):
"""Get SCIM Resource Types"""
return [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "User",
"name": "User",
"endpoint": "/Users",
"schema": SCIM_USER_SCHEMA,
"meta": {
"location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User",
"resourceType": "ResourceType",
},
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": SCIM_GROUP_SCHEMA,
"meta": {
"location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group",
"resourceType": "ResourceType",
},
},
]
# SCIM Schemas
@router.get("/Schemas")
async def get_schemas():
"""Get SCIM Schemas"""
return [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": SCIM_USER_SCHEMA,
"name": "User",
"description": "User Account",
"attributes": [
{
"name": "userName",
"type": "string",
"required": True,
"uniqueness": "server",
},
{"name": "displayName", "type": "string", "required": True},
{
"name": "emails",
"type": "complex",
"multiValued": True,
"required": True,
},
{"name": "active", "type": "boolean", "required": False},
],
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": SCIM_GROUP_SCHEMA,
"name": "Group",
"description": "Group",
"attributes": [
{"name": "displayName", "type": "string", "required": True},
{
"name": "members",
"type": "complex",
"multiValued": True,
"required": False,
},
],
},
]
# Users endpoints
@router.get("/Users", response_model=SCIMListResponse)
async def get_users(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
):
"""List SCIM Users"""
skip = startIndex - 1
limit = count
# Get users from database
if filter:
# Simple filter parsing - supports userName eq "email"
# In production, you'd want a more robust filter parser
if "userName eq" in filter:
email = filter.split('"')[1]
user = Users.get_user_by_email(email)
users_list = [user] if user else []
total = 1 if user else 0
else:
response = Users.get_users(skip=skip, limit=limit)
users_list = response["users"]
total = response["total"]
else:
response = Users.get_users(skip=skip, limit=limit)
users_list = response["users"]
total = response["total"]
# Convert to SCIM format
scim_users = [user_to_scim(user, request) for user in users_list]
return SCIMListResponse(
totalResults=total,
itemsPerPage=len(scim_users),
startIndex=startIndex,
Resources=scim_users,
)
@router.get("/Users/{user_id}", response_model=SCIMUser)
async def get_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Get SCIM User by ID"""
user = Users.get_user_by_id(user_id)
if not user:
return scim_error(
status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
)
return user_to_scim(user, request)
@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
async def create_user(
request: Request,
user_data: SCIMUserCreateRequest,
_: bool = Depends(get_scim_auth),
):
"""Create SCIM User"""
# Check if user already exists
existing_user = Users.get_user_by_email(user_data.userName)
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {user_data.userName} already exists",
)
# Create user
user_id = str(uuid.uuid4())
email = user_data.emails[0].value if user_data.emails else user_data.userName
# Parse name if provided
name = user_data.displayName
if user_data.name:
if user_data.name.formatted:
name = user_data.name.formatted
elif user_data.name.givenName or user_data.name.familyName:
name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
# Get profile image if provided
profile_image = "/user.png"
if user_data.photos and len(user_data.photos) > 0:
profile_image = user_data.photos[0].value
# Create user
new_user = Users.insert_new_user(
id=user_id,
name=name,
email=email,
profile_image_url=profile_image,
role="user" if user_data.active else "pending",
)
if not new_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user",
)
return user_to_scim(new_user, request)
@router.put("/Users/{user_id}", response_model=SCIMUser)
async def update_user(
user_id: str,
request: Request,
user_data: SCIMUserUpdateRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM User (full update)"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
# Build update dict
update_data = {}
if user_data.userName:
update_data["email"] = user_data.userName
if user_data.displayName:
update_data["name"] = user_data.displayName
elif user_data.name:
if user_data.name.formatted:
update_data["name"] = user_data.name.formatted
elif user_data.name.givenName or user_data.name.familyName:
update_data["name"] = (
f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
)
if user_data.emails and len(user_data.emails) > 0:
update_data["email"] = user_data.emails[0].value
if user_data.active is not None:
update_data["role"] = "user" if user_data.active else "pending"
if user_data.photos and len(user_data.photos) > 0:
update_data["profile_image_url"] = user_data.photos[0].value
# Update user
updated_user = Users.update_user_by_id(user_id, update_data)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
return user_to_scim(updated_user, request)
@router.patch("/Users/{user_id}", response_model=SCIMUser)
async def patch_user(
user_id: str,
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM User (partial update)"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
update_data = {}
for operation in patch_data.Operations:
op = operation.op.lower()
path = operation.path
value = operation.value
if op == "replace":
if path == "active":
update_data["role"] = "user" if value else "pending"
elif path == "userName":
update_data["email"] = value
elif path == "displayName":
update_data["name"] = value
elif path == "emails[primary eq true].value":
update_data["email"] = value
elif path == "name.formatted":
update_data["name"] = value
# Update user
if update_data:
updated_user = Users.update_user_by_id(user_id, update_data)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
else:
updated_user = user
return user_to_scim(updated_user, request)
@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Delete SCIM User"""
user = Users.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
success = Users.delete_user_by_id(user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete user",
)
return None
# Groups endpoints
@router.get("/Groups", response_model=SCIMListResponse)
async def get_groups(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
):
"""List SCIM Groups"""
# Get all groups
groups_list = Groups.get_groups()
# Apply pagination
total = len(groups_list)
start = startIndex - 1
end = start + count
paginated_groups = groups_list[start:end]
# Convert to SCIM format
scim_groups = [group_to_scim(group, request) for group in paginated_groups]
return SCIMListResponse(
totalResults=total,
itemsPerPage=len(scim_groups),
startIndex=startIndex,
Resources=scim_groups,
)
@router.get("/Groups/{group_id}", response_model=SCIMGroup)
async def get_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Get SCIM Group by ID"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
return group_to_scim(group, request)
@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
async def create_group(
request: Request,
group_data: SCIMGroupCreateRequest,
_: bool = Depends(get_scim_auth),
):
"""Create SCIM Group"""
# Extract member IDs
member_ids = []
if group_data.members:
for member in group_data.members:
member_ids.append(member.value)
# Create group
from open_webui.models.groups import GroupForm
form = GroupForm(
name=group_data.displayName,
description="",
)
# Need to get the creating user's ID - we'll use the first admin
admin_user = Users.get_super_admin_user()
if not admin_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No admin user found",
)
new_group = Groups.insert_new_group(admin_user.id, form)
if not new_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create group",
)
# Add members if provided
if member_ids:
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=new_group.name,
description=new_group.description,
user_ids=member_ids,
)
Groups.update_group_by_id(new_group.id, update_form)
new_group = Groups.get_group_by_id(new_group.id)
return group_to_scim(new_group, request)
@router.put("/Groups/{group_id}", response_model=SCIMGroup)
async def update_group(
group_id: str,
request: Request,
group_data: SCIMGroupUpdateRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM Group (full update)"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
# Build update form
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=group_data.displayName if group_data.displayName else group.name,
description=group.description,
)
# Handle members if provided
if group_data.members is not None:
member_ids = [member.value for member in group_data.members]
update_form.user_ids = member_ids
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
@router.patch("/Groups/{group_id}", response_model=SCIMGroup)
async def patch_group(
group_id: str,
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
):
"""Update SCIM Group (partial update)"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
from open_webui.models.groups import GroupUpdateForm
update_form = GroupUpdateForm(
name=group.name,
description=group.description,
user_ids=group.user_ids.copy() if group.user_ids else [],
)
for operation in patch_data.Operations:
op = operation.op.lower()
path = operation.path
value = operation.value
if op == "replace":
if path == "displayName":
update_form.name = value
elif path == "members":
# Replace all members
update_form.user_ids = [member["value"] for member in value]
elif op == "add":
if path == "members":
# Add members
if isinstance(value, list):
for member in value:
if isinstance(member, dict) and "value" in member:
if member["value"] not in update_form.user_ids:
update_form.user_ids.append(member["value"])
elif op == "remove":
if path and path.startswith("members[value eq"):
# Remove specific member
member_id = path.split('"')[1]
if member_id in update_form.user_ids:
update_form.user_ids.remove(member_id)
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
):
"""Delete SCIM Group"""
group = Groups.get_group_by_id(group_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
success = Groups.delete_group_by_id(group_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete group",
)
return None
+12 -9
View File
@@ -5,6 +5,8 @@ import time
import re
import aiohttp
from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.models.tools import (
ToolForm,
@@ -14,16 +16,15 @@ from open_webui.models.tools import (
Tools,
)
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.tools import get_tool_servers_data
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import CACHE_DIR, ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
@@ -74,15 +75,17 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
if user.role != "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
# Admin can see all tools
return tools
else:
tools = [
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control)
]
return tools
return tools
############################
@@ -92,7 +95,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
@router.get("/list", response_model=list[ToolUserResponse])
async def get_tool_list(user=Depends(get_verified_user)):
if user.role == "admin":
if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS:
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "write")
+59 -3
View File
@@ -1,5 +1,13 @@
import logging
from typing import Optional
import base64
import io
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response, StreamingResponse, FileResponse
from pydantic import BaseModel
from open_webui.models.auths import Auths
from open_webui.models.groups import Groups
@@ -21,9 +29,8 @@ from open_webui.socket.main import (
get_user_active_status,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions, has_permission
@@ -134,7 +141,9 @@ class SharingPermissions(BaseModel):
class ChatPermissions(BaseModel):
controls: bool = True
valves: bool = True
system_prompt: bool = True
params: bool = True
file_upload: bool = True
delete: bool = True
edit: bool = True
@@ -327,6 +336,43 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
)
############################
# GetUserProfileImageById
############################
@router.get("/{user_id}/profile/image")
async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
if user:
if user.profile_image_url:
# check if it's url or base64
if user.profile_image_url.startswith("http"):
return Response(
status_code=status.HTTP_302_FOUND,
headers={"Location": user.profile_image_url},
)
elif user.profile_image_url.startswith("data:image"):
try:
header, base64_data = user.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
return StreamingResponse(
image_buffer,
media_type="image/png",
headers={"Content-Disposition": "inline; filename=image.png"},
)
except Exception as e:
pass
return FileResponse(f"{STATIC_DIR}/user.png")
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserActiveStatusById
############################
@@ -455,3 +501,13 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
############################
# GetUserGroupsById
############################
@router.get("/{user_id}/groups")
async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)):
return Groups.get_groups_by_member_id(user_id)
+12 -5
View File
@@ -22,9 +22,11 @@ from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
WEBSOCKET_REDIS_CLUSTER,
WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
REDIS_KEY_PREFIX,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
@@ -85,6 +87,7 @@ if WEBSOCKET_MANAGER == "redis":
redis_sentinels=get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
),
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
async_mode=True,
)
@@ -92,19 +95,22 @@ if WEBSOCKET_MANAGER == "redis":
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
SESSION_POOL = RedisDict(
"open-webui:session_pool",
f"{REDIS_KEY_PREFIX}:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USER_POOL = RedisDict(
"open-webui:user_pool",
f"{REDIS_KEY_PREFIX}:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USAGE_POOL = RedisDict(
"open-webui:usage_pool",
f"{REDIS_KEY_PREFIX}:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
clean_up_lock = RedisLock(
@@ -112,6 +118,7 @@ if WEBSOCKET_MANAGER == "redis":
lock_name="usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
@@ -126,7 +133,7 @@ else:
YDOC_MANAGER = YdocManager(
redis=REDIS,
redis_key_prefix="open-webui:ydoc:documents",
redis_key_prefix=f"{REDIS_KEY_PREFIX}:ydoc:documents",
)
@@ -581,7 +588,7 @@ async def yjs_document_leave(sid, data):
)
if (
YDOC_MANAGER.document_exists(document_id)
await YDOC_MANAGER.document_exists(document_id)
and len(await YDOC_MANAGER.get_users(document_id)) == 0
):
log.info(f"Cleaning up document {document_id} as no users are left")
+20 -5
View File
@@ -1,18 +1,30 @@
import json
import uuid
from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_KEY_PREFIX
from typing import Optional, List, Tuple
import pycrdt as Y
class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
def __init__(
self,
redis_url,
lock_name,
timeout_secs,
redis_sentinels=[],
redis_cluster=False,
):
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=redis_cluster,
decode_responses=True,
)
def aquire_lock(self):
@@ -35,10 +47,13 @@ class RedisLock:
class RedisDict:
def __init__(self, name, redis_url, redis_sentinels=[]):
def __init__(self, name, redis_url, redis_sentinels=[], redis_cluster=False):
self.name = name
self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=redis_cluster,
decode_responses=True,
)
def __setitem__(self, key, value):
@@ -97,7 +112,7 @@ class YdocManager:
def __init__(
self,
redis=None,
redis_key_prefix: str = "open-webui:ydoc:documents",
redis_key_prefix: str = f"{REDIS_KEY_PREFIX}:ydoc:documents",
):
self._updates = {}
self._users = {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+4 -4
View File
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
from fastapi import Request
from typing import Dict, List, Optional
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, REDIS_KEY_PREFIX
log = logging.getLogger(__name__)
@@ -19,9 +19,9 @@ tasks: Dict[str, asyncio.Task] = {}
item_tasks = {}
REDIS_TASKS_KEY = "open-webui:tasks"
REDIS_ITEM_TASKS_KEY = "open-webui:tasks:item"
REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
REDIS_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks"
REDIS_ITEM_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks:item"
REDIS_PUBSUB_CHANNEL = f"{REDIS_KEY_PREFIX}:tasks:commands"
async def redis_task_command_listener(app):
+1 -1
View File
@@ -195,7 +195,7 @@ class AuditLoggingMiddleware:
try:
user = get_current_user(
request, None, get_http_authorization_cred(auth_header)
request, None, None, get_http_authorization_cred(auth_header)
)
return user
except Exception as e:
+56 -12
View File
@@ -8,6 +8,12 @@ import requests
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import json
from datetime import datetime, timedelta
import pytz
from pytz import UTC
@@ -18,7 +24,11 @@ from opentelemetry import trace
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
OFFLINE_MODE,
LICENSE_BLOB,
pk,
WEBUI_SECRET_KEY,
TRUSTED_SIGNATURE_KEY,
STATIC_DIR,
@@ -74,6 +84,18 @@ def override_static(path: str, content: str):
def get_license_data(app, key):
def data_handler(data):
for k, v in data.items():
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
def handler(u):
res = requests.post(
f"{u}/api/v1/license/",
@@ -83,16 +105,7 @@ def get_license_data(app, key):
if getattr(res, "ok", False):
payload = getattr(res, "json", lambda: {})()
for k, v in payload.items():
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
data_handler(payload)
return True
else:
log.error(
@@ -100,13 +113,44 @@ def get_license_data(app, key):
)
if key:
us = ["https://api.openwebui.com", "https://licenses.api.openwebui.com"]
us = [
"https://api.openwebui.com",
"https://licenses.api.openwebui.com",
]
try:
for u in us:
if handler(u):
return True
except Exception as ex:
log.exception(f"License: Uncaught Exception: {ex}")
try:
if LICENSE_BLOB:
nl = 12
kb = hashlib.sha256((key.replace("-", "").upper()).encode()).digest()
def nt(b):
return b[:nl], b[nl:]
lb = base64.b64decode(LICENSE_BLOB)
ln, lt = nt(lb)
aesgcm = AESGCM(kb)
p = json.loads(aesgcm.decrypt(ln, lt, None))
pk.verify(base64.b64decode(p["s"]), p["p"].encode())
pb = base64.b64decode(p["p"])
pn, pt = nt(pb)
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
if not data.get("exp") and data.get("exp") < datetime.now().date():
return False
data_handler(data)
return True
except Exception as e:
log.error(f"License: {e}")
return False
@@ -177,7 +221,7 @@ def get_current_user(
token = request.cookies.get("token")
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
raise HTTPException(status_code=401, detail="Not authenticated")
# auth by api key
if token.startswith("sk-"):
+11 -6
View File
@@ -5,8 +5,6 @@ from typing import TYPE_CHECKING
from loguru import logger
from opentelemetry import trace
from open_webui.env import (
AUDIT_UVICORN_LOGGER_NAMES,
AUDIT_LOG_FILE_ROTATION_SIZE,
@@ -14,6 +12,7 @@ from open_webui.env import (
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
)
@@ -30,13 +29,16 @@ def stdout_format(record: "Record") -> str:
Returns:
str: A formatted log string intended for stdout.
"""
record["extra"]["extra_json"] = json.dumps(record["extra"])
if record["extra"]:
record["extra"]["extra_json"] = json.dumps(record["extra"])
extra_format = " - {extra[extra_json]}"
else:
extra_format = ""
return (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level> - {extra[extra_json]}"
"\n{exception}"
"<level>{message}</level>" + extra_format + "\n{exception}"
)
@@ -65,6 +67,10 @@ class InterceptHandler(logging.Handler):
logger.opt(depth=depth, exception=record.exc_info).bind(
**self._get_extras()
).log(level, record.getMessage())
if ENABLE_OTEL and ENABLE_OTEL_LOGS:
from open_webui.utils.telemetry.logs import otel_handler
otel_handler.emit(record)
def _get_extras(self):
if not ENABLE_OTEL:
@@ -126,7 +132,6 @@ def start_logger():
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
)
if AUDIT_LOG_LEVEL != "NONE":
try:
logger.add(
+176 -110
View File
@@ -19,7 +19,7 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException
from starlette.responses import Response, StreamingResponse
from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.chats import Chats
@@ -83,6 +83,7 @@ from open_webui.utils.filter import (
process_filter_functions,
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_model_system_prompt_to_body
from open_webui.tasks import create_task
@@ -94,6 +95,7 @@ from open_webui.config import (
from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
)
@@ -683,6 +685,7 @@ def apply_params_to_form_data(form_data, model):
open_webui_params = {
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"system": str,
}
@@ -774,8 +777,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if folder and folder.data:
if "system_prompt" in folder.data:
form_data["messages"] = add_or_update_system_message(
folder.data["system_prompt"], form_data["messages"]
form_data = apply_model_system_prompt_to_body(
folder.data["system_prompt"], form_data, metadata, user
)
if "files" in folder.data:
form_data["files"] = [
@@ -929,7 +932,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
if tools_dict:
if metadata.get("function_calling") == "native":
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
@@ -1251,91 +1254,111 @@ async def process_chat_response(
# Non-streaming response
if not isinstance(response, StreamingResponse):
if event_emitter:
if "error" in response:
error = response["error"].get("detail", response["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
if isinstance(response, dict) or isinstance(response, JSONResponse):
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response["selected_model_id"],
},
)
if isinstance(response, JSONResponse) and isinstance(
response.body, bytes
):
try:
response_data = json.loads(response.body.decode("utf-8"))
except json.JSONDecodeError:
response_data = {"error": {"detail": "Invalid JSON response"}}
else:
response_data = response
choices = response.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
},
}
)
# Save message in the database
if "error" in response_data:
error = response_data["error"].get("detail", response_data["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
"error": {"content": error},
},
)
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
},
)
choices = response_data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response_data["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response_data,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
}
)
await background_tasks_handler()
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
response = {
**extra_response,
**response,
}
await background_tasks_handler()
if events and isinstance(events, list):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response_data = {
**extra_response,
**response_data,
}
if isinstance(response, dict):
response = response_data
if isinstance(response, JSONResponse):
response = JSONResponse(
content=response_data,
headers=response.headers,
status_code=response.status_code,
)
return response
else:
@@ -1381,14 +1404,6 @@ async def process_chat_response(
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get("model", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"model": model_id,
},
)
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = (
@@ -1410,13 +1425,18 @@ async def process_chat_response(
for block in content_blocks:
if block["type"] == "text":
content = f"{content}{block['content'].strip()}\n"
block_content = block["content"].strip()
if block_content:
content = f"{content}{block_content}\n"
elif block["type"] == "tool_calls":
attributes = block.get("attributes", {})
tool_calls = block.get("content", [])
results = block.get("results", [])
if content and not content.endswith("\n"):
content += "\n"
if results:
tool_calls_display_content = ""
@@ -1439,12 +1459,12 @@ async def process_chat_response(
break
if tool_result:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
if not raw:
content = f"{content}\n{tool_calls_display_content}\n\n"
content = f"{content}{tool_calls_display_content}"
else:
tool_calls_display_content = ""
@@ -1457,10 +1477,10 @@ async def process_chat_response(
"arguments", ""
)
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
if not raw:
content = f"{content}\n{tool_calls_display_content}\n\n"
content = f"{content}{tool_calls_display_content}"
elif block["type"] == "reasoning":
reasoning_display_content = "\n".join(
@@ -1470,16 +1490,26 @@ async def process_chat_response(
reasoning_duration = block.get("duration", None)
start_tag = block.get("start_tag", "")
end_tag = block.get("end_tag", "")
if content and not content.endswith("\n"):
content += "\n"
if reasoning_duration is not None:
if raw:
content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
content = (
f'{content}{start_tag}{block["content"]}{end_tag}\n'
)
else:
content = f'{content}\n<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
content = f'{content}<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
else:
if raw:
content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
content = (
f'{content}{start_tag}{block["content"]}{end_tag}\n'
)
else:
content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
elif block["type"] == "code_interpreter":
attributes = block.get("attributes", {})
@@ -1499,26 +1529,30 @@ async def process_chat_response(
# Keep content as is - either closing backticks or no backticks
content = content_stripped + original_whitespace
if content and not content.endswith("\n"):
content += "\n"
if output:
output = html.escape(json.dumps(output))
if raw:
content = f'{content}\n<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n```output\n{output}\n```\n'
content = f'{content}<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n```output\n{output}\n```\n'
else:
content = f'{content}\n<details type="code_interpreter" done="true" output="{output}">\n<summary>Analyzed</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
content = f'{content}<details type="code_interpreter" done="true" output="{output}">\n<summary>Analyzed</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
else:
if raw:
content = f'{content}\n<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n'
content = f'{content}<code_interpreter type="code" lang="{lang}">\n{block["content"]}\n</code_interpreter>\n'
else:
content = f'{content}\n<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
content = f'{content}<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{block["content"]}\n```\n</details>\n'
else:
block_content = str(block["content"]).strip()
content = f"{content}{block['type']}: {block_content}\n"
if block_content:
content = f"{content}{block['type']}: {block_content}\n"
return content.strip()
def convert_content_blocks_to_messages(content_blocks):
def convert_content_blocks_to_messages(content_blocks, raw=False):
messages = []
temp_blocks = []
@@ -1527,7 +1561,7 @@ async def process_chat_response(
messages.append(
{
"role": "assistant",
"content": serialize_content_blocks(temp_blocks),
"content": serialize_content_blocks(temp_blocks, raw),
"tool_calls": block.get("content"),
}
)
@@ -1547,7 +1581,7 @@ async def process_chat_response(
temp_blocks.append(block)
if temp_blocks:
content = serialize_content_blocks(temp_blocks)
content = serialize_content_blocks(temp_blocks, raw)
if content:
messages.append(
{
@@ -1804,6 +1838,15 @@ async def process_chat_response(
response_tool_calls = []
delta_count = 0
delta_chunk_size = max(
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
int(
metadata.get("params", {}).get("stream_delta_chunk_size")
or 1
),
)
async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line
data = line
@@ -1943,8 +1986,8 @@ async def process_chat_response(
):
reasoning_block = {
"type": "reasoning",
"start_tag": "think",
"end_tag": "/think",
"start_tag": "<think>",
"end_tag": "</think>",
"attributes": {
"type": "reasoning_content"
},
@@ -2051,12 +2094,23 @@ async def process_chat_response(
),
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
if delta:
delta_count += 1
if delta_count >= delta_chunk_size:
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
delta_count = 0
else:
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
except Exception as e:
done = "data: [DONE]" in line
if done:
@@ -2083,6 +2137,15 @@ async def process_chat_response(
}
)
if content_blocks[-1]["type"] == "reasoning":
reasoning_block = content_blocks[-1]
if reasoning_block.get("ended_at") is None:
reasoning_block["ended_at"] = time.time()
reasoning_block["duration"] = int(
reasoning_block["ended_at"]
- reasoning_block["started_at"]
)
if response_tool_calls:
tool_calls.append(response_tool_calls)
@@ -2095,6 +2158,7 @@ async def process_chat_response(
tool_call_retries = 0
while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
tool_call_retries += 1
response_tool_calls = tool_calls.pop(0)
@@ -2246,7 +2310,9 @@ async def process_chat_response(
"tools": form_data["tools"],
"messages": [
*form_data["messages"],
*convert_content_blocks_to_messages(content_blocks),
*convert_content_blocks_to_messages(
content_blocks, True
),
],
}
+1 -1
View File
@@ -227,7 +227,7 @@ def openai_chat_chunk_message_template(
if tool_calls:
template["choices"][0]["delta"]["tool_calls"] = tool_calls
if not content and not tool_calls:
if not content and not reasoning_content and not tool_calls:
template["choices"][0]["finish_reason"] = "stop"
if usage:
+15 -7
View File
@@ -27,6 +27,7 @@ from open_webui.config import (
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_BLOCKED_GROUPS,
OAUTH_ROLES_CLAIM,
OAUTH_SUB_CLAIM,
OAUTH_GROUPS_CLAIM,
OAUTH_EMAIL_CLAIM,
OAUTH_PICTURE_CLAIM,
@@ -65,6 +66,7 @@ auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMEN
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
@@ -88,11 +90,12 @@ class OAuthManager:
return self.oauth.create_client(provider_name)
def get_user_role(self, user, user_data):
if user and Users.get_num_users() == 1:
user_count = Users.get_num_users()
if user and user_count == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
log.debug("Assigning the only user the admin role")
return "admin"
if not user and Users.get_num_users() == 0:
if not user and user_count == 0:
# If there are no users, assign the role "admin", as the first user will be an admin
log.debug("Assigning the first user the admin role")
return "admin"
@@ -358,11 +361,18 @@ class OAuthManager:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if auth_manager_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}"
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided
@@ -449,8 +459,6 @@ class OAuthManager:
log.debug(f"Updated profile picture for user {user.email}")
if not user:
user_count = Users.get_num_users()
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
@@ -521,7 +529,7 @@ class OAuthManager:
response.set_cookie(
key="token",
value=jwt_token,
httponly=True, # Ensures the cookie is not accessible via JavaScript
httponly=False, # Required for frontend access
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
@@ -540,6 +548,6 @@ class OAuthManager:
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
if redirect_base_url.endswith("/"):
redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth#token={jwt_token}"
redirect_url = f"{redirect_base_url}/auth"
return RedirectResponse(url=redirect_url, headers=response.headers)
+1
View File
@@ -69,6 +69,7 @@ def remove_open_webui_params(params: dict) -> dict:
"""
open_webui_params = {
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"system": str,
}
+44 -11
View File
@@ -10,6 +10,9 @@ from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT
log = logging.getLogger(__name__)
_CONNECTION_CACHE = {}
class SentinelRedisProxy:
def __init__(self, sentinel, service, *, async_mode: bool = True, **kw):
self._sentinel = sentinel
@@ -93,8 +96,8 @@ class SentinelRedisProxy:
def parse_redis_service_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis":
raise ValueError("Invalid Redis URL scheme. Must be 'redis'.")
if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss":
raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.")
return {
"username": parsed_url.username or None,
@@ -106,8 +109,25 @@ def parse_redis_service_url(redis_url):
def get_redis_connection(
redis_url, redis_sentinels, async_mode=False, decode_responses=True
redis_url,
redis_sentinels,
redis_cluster=False,
async_mode=False,
decode_responses=True,
):
cache_key = (
redis_url,
tuple(redis_sentinels) if redis_sentinels else (),
async_mode,
decode_responses,
)
if cache_key in _CONNECTION_CACHE:
return _CONNECTION_CACHE[cache_key]
connection = None
if async_mode:
import redis.asyncio as redis
@@ -122,15 +142,19 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
return SentinelRedisProxy(
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
elif redis_url:
return redis.from_url(redis_url, decode_responses=decode_responses)
else:
return None
connection = redis.from_url(redis_url, decode_responses=decode_responses)
else:
import redis
@@ -144,15 +168,24 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
return SentinelRedisProxy(
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
elif redis_url:
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
else:
return None
connection = redis.Redis.from_url(
redis_url, decode_responses=decode_responses
)
_CONNECTION_CACHE[cache_key] = connection
return connection
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
@@ -1,31 +0,0 @@
import threading
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import BatchSpanProcessor
class LazyBatchSpanProcessor(BatchSpanProcessor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.done = True
with self.condition:
self.condition.notify_all()
self.worker_thread.join()
self.done = False
self.worker_thread = None
def on_end(self, span: ReadableSpan) -> None:
if self.worker_thread is None:
self.worker_thread = threading.Thread(
name=self.__class__.__name__, target=self.worker, daemon=True
)
self.worker_thread.start()
super().on_end(span)
def shutdown(self) -> None:
self.done = True
with self.condition:
self.condition.notify_all()
if self.worker_thread:
self.worker_thread.join()
self.span_exporter.shutdown()
@@ -0,0 +1,53 @@
import logging
from base64 import b64encode
from opentelemetry.sdk._logs import (
LoggingHandler,
LoggerProvider,
)
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter as HttpOTLPLogExporter,
)
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from open_webui.env import (
OTEL_SERVICE_NAME,
OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
OTEL_LOGS_EXPORTER_OTLP_INSECURE,
OTEL_LOGS_BASIC_AUTH_USERNAME,
OTEL_LOGS_BASIC_AUTH_PASSWORD,
OTEL_LOGS_OTLP_SPAN_EXPORTER,
)
def setup_logging():
headers = []
if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD:
auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}"
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http":
exporter = HttpOTLPLogExporter(
endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
headers=headers,
)
else:
exporter = OTLPLogExporter(
endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_LOGS_EXPORTER_OTLP_INSECURE,
headers=headers,
)
logger_provider = LoggerProvider(resource=resource)
set_logger_provider(logger_provider)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
otel_handler = LoggingHandler(logger_provider=logger_provider)
return otel_handler
otel_handler = setup_logging()
+45 -13
View File
@@ -19,37 +19,69 @@ from __future__ import annotations
import time
from typing import Dict, List, Sequence, Any
from base64 import b64encode
from fastapi import FastAPI, Request
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter as OTLPHttpMetricExporter,
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.view import View
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
from opentelemetry.sdk.resources import Resource
from open_webui.env import (
OTEL_SERVICE_NAME,
OTEL_METRICS_EXPORTER_OTLP_ENDPOINT,
OTEL_METRICS_BASIC_AUTH_USERNAME,
OTEL_METRICS_BASIC_AUTH_PASSWORD,
OTEL_METRICS_OTLP_SPAN_EXPORTER,
OTEL_METRICS_EXPORTER_OTLP_INSECURE,
)
from open_webui.socket.main import get_active_user_ids
from open_webui.models.users import Users
_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds
def _build_meter_provider() -> MeterProvider:
def _build_meter_provider(resource: Resource) -> MeterProvider:
"""Return a configured MeterProvider."""
headers = []
if OTEL_METRICS_BASIC_AUTH_USERNAME and OTEL_METRICS_BASIC_AUTH_PASSWORD:
auth_string = (
f"{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}"
)
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
# Periodic reader pushes metrics over OTLP/gRPC to collector
readers: List[PeriodicExportingMetricReader] = [
PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT),
export_interval_millis=_EXPORT_INTERVAL_MILLIS,
)
]
if OTEL_METRICS_OTLP_SPAN_EXPORTER == "http":
readers: List[PeriodicExportingMetricReader] = [
PeriodicExportingMetricReader(
OTLPHttpMetricExporter(
endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers
),
export_interval_millis=_EXPORT_INTERVAL_MILLIS,
)
]
else:
readers: List[PeriodicExportingMetricReader] = [
PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_METRICS_EXPORTER_OTLP_INSECURE,
headers=headers,
),
export_interval_millis=_EXPORT_INTERVAL_MILLIS,
)
]
# Optional view to limit cardinality: drop user-agent etc.
views: List[View] = [
@@ -70,17 +102,17 @@ def _build_meter_provider() -> MeterProvider:
]
provider = MeterProvider(
resource=Resource.create({SERVICE_NAME: OTEL_SERVICE_NAME}),
resource=resource,
metric_readers=list(readers),
views=views,
)
return provider
def setup_metrics(app: FastAPI) -> None:
def setup_metrics(app: FastAPI, resource: Resource) -> None:
"""Attach OTel metrics middleware to *app* and initialise provider."""
metrics.set_meter_provider(_build_meter_provider())
metrics.set_meter_provider(_build_meter_provider(resource))
meter = metrics.get_meter(__name__)
# Instruments
+6 -9
View File
@@ -1,15 +1,16 @@
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HttpOTLPSpanExporter,
)
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from sqlalchemy import Engine
from base64 import b64encode
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
from open_webui.utils.telemetry.metrics import setup_metrics
from open_webui.env import (
@@ -25,11 +26,8 @@ from open_webui.env import (
def setup(app: FastAPI, db_engine: Engine):
# set up trace
trace.set_tracer_provider(
TracerProvider(
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
)
)
resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
trace.set_tracer_provider(TracerProvider(resource=resource))
# Add basic auth header only if both username and password are not empty
headers = []
@@ -42,7 +40,6 @@ def setup(app: FastAPI, db_engine: Engine):
if OTEL_OTLP_SPAN_EXPORTER == "http":
exporter = HttpOTLPSpanExporter(
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
insecure=OTEL_EXPORTER_OTLP_INSECURE,
headers=headers,
)
else:
@@ -51,9 +48,9 @@ def setup(app: FastAPI, db_engine: Engine):
insecure=OTEL_EXPORTER_OTLP_INSECURE,
headers=headers,
)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument()
# set up metrics only if enabled
if ENABLE_OTEL_METRICS:
setup_metrics(app)
setup_metrics(app, resource)
+23 -11
View File
@@ -377,7 +377,6 @@ def convert_openapi_to_tool_payload(openapi_spec):
for method, operation in methods.items():
if operation.get("operationId"):
tool = {
"type": "function",
"name": operation.get("operationId"),
"description": operation.get(
"description",
@@ -399,10 +398,16 @@ def convert_openapi_to_tool_payload(openapi_spec):
description += (
f". Possible values: {', '.join(param_schema.get('enum'))}"
)
tool["parameters"]["properties"][param_name] = {
param_property = {
"type": param_schema.get("type"),
"description": description,
}
# Include items property for array types (required by OpenAI)
if param_schema.get("type") == "array" and "items" in param_schema:
param_property["items"] = param_schema["items"]
tool["parameters"]["properties"][param_name] = param_property
if param.get("required"):
tool["parameters"]["required"].append(param_name)
@@ -489,15 +494,7 @@ async def get_tool_servers_data(
if server.get("config", {}).get("enable"):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
if "://" in openapi_path:
# If it contains "://", it's a full URL
full_url = openapi_path
else:
if not openapi_path.startswith("/"):
# Ensure the path starts with a slash
openapi_path = f"/{openapi_path}"
full_url = f"{server.get('url')}{openapi_path}"
full_url = get_tool_server_url(server.get("url"), openapi_path)
info = server.get("info", {})
@@ -528,6 +525,8 @@ async def get_tool_servers_data(
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
openapi_data["info"] = openapi_data.get("info", {})
if "name" in info:
openapi_data["info"]["title"] = info.get("name", "Tool Server")
@@ -643,3 +642,16 @@ async def execute_tool_server(
error = str(err)
log.exception(f"API Request Error: {error}")
return {"error": error}
def get_tool_server_url(url: Optional[str], path: str) -> str:
"""
Build the full URL for a tool server, given a base url and a path.
"""
if "://" in path:
# If it contains "://", it's a full URL
return path
if not path.startswith("/"):
# Ensure the path starts with a slash
path = f"/{path}"
return f"{url}{path}"