chore: format

This commit is contained in:
Timothy Jaeryang Baek
2026-06-01 13:56:55 -07:00
parent 1f49ebf431
commit 6fce92aa12
139 changed files with 92569 additions and 3249 deletions
+2 -6
View File
@@ -685,9 +685,7 @@ BYPASS_RETRIEVAL_ACCESS_CONTROL = os.getenv('BYPASS_RETRIEVAL_ACCESS_CONTROL', '
# web-search-*, or knowledge-base collection are allowed through access control
# for non-admin users. When False (default), unknown collection names are
# denied — closing the legacy unscoped namespace.
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS = (
os.getenv('ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS', 'False').lower() == 'true'
)
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS = os.getenv('ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS', 'False').lower() == 'true'
# When enabled, skips pydub-based preprocessing (format conversion, compression,
# and chunked splitting) before sending files to processing engines. Useful when
@@ -800,9 +798,7 @@ PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset(
# Max stored length (bytes) of a data:image profile URI; bounds Postgres/Redis
# bloat from inline avatars and model icons. Unset (default) disables the cap.
_profile_image_max_data_uri_size = os.getenv('PROFILE_IMAGE_MAX_DATA_URI_SIZE', '').strip()
PROFILE_IMAGE_MAX_DATA_URI_SIZE = (
int(_profile_image_max_data_uri_size) if _profile_image_max_data_uri_size else None
)
PROFILE_IMAGE_MAX_DATA_URI_SIZE = int(_profile_image_max_data_uri_size) if _profile_image_max_data_uri_size else None
####################################
# Forward Headers
+3
View File
@@ -127,10 +127,13 @@ class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
impl = types.UnicodeText
cache_ok = True
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return json.dumps(value) if value is not None else None
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return json.loads(value) if value is not None else None
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
+5 -1
View File
@@ -2061,7 +2061,9 @@ async def chat_completion(
if metadata.get('chat_id') and metadata.get('message_id'):
# Update the chat message with the error
try:
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith('channel:'):
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
'chat_id', ''
).startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@@ -2969,6 +2971,8 @@ async def check_db_health():
"""Verify database connectivity by issuing a lightweight ping."""
await async_db_ping()
return {'status': True}
# --- static assets & files ---
# Serve build-time static assets (CSS, JS, images, favicon, etc.)
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
+20 -1
View File
@@ -1,4 +1,5 @@
from __future__ import annotations
# Alembic environment configuration runner.
# Coordinates database migrations in both offline and online execution modes.
import logging.config
@@ -9,11 +10,13 @@ from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_par
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from sqlalchemy import create_engine, engine_from_config, pool
alembic_config = alembic.context.config
if alembic_config.config_file_name:
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
if LOG_FORMAT == 'json':
from open_webui.env import JSONFormatter
for log_handler in logging.root.handlers:
log_handler.setFormatter(JSONFormatter())
migration_metadata = Auth.metadata
@@ -23,12 +26,21 @@ if ssl_query_params:
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
if target_db_url:
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
def run_migrations_offline() -> None:
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure(url=db_connection_url, target_metadata=migration_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
alembic.context.configure(
url=db_connection_url,
target_metadata=migration_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
def _get_engine_connectable():
"""Build the database engine based on target URL and authentication credentials."""
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
@@ -37,17 +49,22 @@ def _get_engine_connectable():
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
if raw_db_path.startswith('/'):
raw_db_path = raw_db_path[1:]
def _sqlite_cipher_creator():
import sqlcipher3
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return cipher_conn
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
return engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
def run_migrations_online() -> None:
"""Execute migrations against a live database connection."""
live_connectable = _get_engine_connectable()
@@ -58,6 +75,8 @@ def run_migrations_online() -> None:
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
# Alembic execution entrypoint branch
if alembic.context.is_offline_mode():
run_migrations_offline() # run in offline mode
+2
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from alembic import op # noqa: E402 — alembic runtime context
from sqlalchemy import inspect # metadata inspection
# --- database helper functions ---
def get_existing_tables() -> set[str]:
"""Return table names already present in the database."""
@@ -53,7 +53,9 @@ def upgrade():
if 'channel_member' not in existing_tables:
op.create_table(
'channel_member',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
sa.Column(
'id', sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
@@ -42,9 +42,9 @@ def upgrade() -> None:
# Create indexes (idempotent — no-ops when table was just created
# with the columns above, and safe to call if indexes already exist).
existing_indexes = {
idx['name'] for idx in inspector.get_indexes('oauth_session')
} if 'oauth_session' in existing_tables else set()
existing_indexes = (
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
)
if 'idx_oauth_session_user_id' not in existing_indexes:
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
@@ -5,6 +5,7 @@ Revises: a0b1c2d3e4f5
Create Date: 2026-05-13 21:58:40.832482
"""
from typing import Sequence, Union
from alembic import op
@@ -36,7 +37,9 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'),
sa.UniqueConstraint(
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
),
)
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
@@ -5,6 +5,7 @@ Revises: 3c9b0ca343fd
Create Date: 2026-05-14 04:38:14.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -21,9 +22,18 @@ depends_on: Union[str, Sequence[str], None] = None
# already have correct PKs from 7e5b5dc7342b_init.py.
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
LEGACY_TABLES = {
'auth': ['id'], 'chat': ['id'], 'chatidtag': ['id'], 'document': ['id'],
'file': ['id'], 'function': ['id'], 'memory': ['id'], 'model': ['id'],
'prompt': ['id'], 'tag': ['id', 'user_id'], 'tool': ['id'], 'user': ['id'],
'auth': ['id'],
'chat': ['id'],
'chatidtag': ['id'],
'document': ['id'],
'file': ['id'],
'function': ['id'],
'memory': ['id'],
'model': ['id'],
'prompt': ['id'],
'tag': ['id', 'user_id'],
'tool': ['id'],
'user': ['id'],
}
@@ -52,13 +52,18 @@ def upgrade() -> None:
column('created_at', sa.BigInteger),
)
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
notes = conn.execute(
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
[
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
for note in notes
],
)
with op.batch_alter_table('note', schema=None) as batch_op:
@@ -95,7 +95,9 @@ def upgrade() -> None:
inspector.clear_cache()
if 'calendar_event_attendee' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
op.create_index(
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
)
def downgrade() -> None:
@@ -48,7 +48,9 @@ def upgrade() -> None:
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
sa.UniqueConstraint(
'channel_id', 'file_id', name='uq_channel_file_channel_file'
), # prevent duplicate entries
)
@@ -9,7 +9,8 @@ import sqlalchemy as sa
from alembic import op
from open_webui.internal.db import JSONField
from open_webui.migrations.util import get_existing_tables
revision: str = "7e5b5dc7342b"
revision: str = '7e5b5dc7342b'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@@ -175,12 +176,16 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
],
),
]
# --- migration execution ---
def upgrade() -> None: # deploy initial schema tables
existing_tables = set(get_existing_tables())
for name, columns, constraints in _INITIAL_TABLES:
if name not in existing_tables:
op.create_table(name, *columns, *constraints)
# --- rollback function ---
def downgrade() -> None: # rollback initial schema tables
for table_name, _, _ in reversed(_INITIAL_TABLES):
@@ -182,27 +182,18 @@ def upgrade() -> None:
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
if 'oauth_sub' in user_columns:
rows = conn.execute(
sa.select(_user.c.id, _user.c.oauth_sub)
.where(_user.c.oauth_sub.is_not(None))
).fetchall()
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
for uid, oauth_sub in rows:
if oauth_sub:
provider, sub = (oauth_sub.split('@', 1) if '@' in oauth_sub
else ('oidc', oauth_sub))
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
conn.execute(
sa.update(_user)
.where(_user.c.id == uid)
.values(oauth=json.dumps({provider: {'sub': sub}}))
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
)
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
rows = conn.execute(
sa.select(_user.c.id, _user.c.api_key)
.where(_user.c.api_key.is_not(None))
).fetchall()
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
now = int(time.time())
for uid, key_val in rows:
@@ -233,10 +224,7 @@ def downgrade() -> None:
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
conn = op.get_bind()
rows = conn.execute(
sa.select(_user.c.id, _user.c.oauth)
.where(_user.c.oauth.is_not(None))
).fetchall()
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
for uid, oauth in rows:
try:
@@ -247,11 +235,7 @@ def downgrade() -> None:
except Exception:
oauth_sub = None
conn.execute(
sa.update(_user)
.where(_user.c.id == uid)
.values(oauth_sub=oauth_sub)
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
op.drop_column('user', 'oauth')
@@ -260,11 +244,7 @@ def downgrade() -> None:
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
for uid, key in keys:
conn.execute(
sa.update(_user)
.where(_user.c.id == uid)
.values(api_key=key)
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
op.drop_table('api_key')
@@ -101,9 +101,7 @@ def upgrade():
# Check if shared_chat record already exists (idempotent)
existing_shared = conn.execute(
sa.select(shared_chat_t.c.id).where(
shared_chat_t.c.id == share_token
)
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
).fetchone()
if not existing_shared:
+28 -9
View File
@@ -119,7 +119,13 @@ class AuthsTable:
session.add(credential)
created_user = await Users.insert_new_user(
new_id, name, email, profile_image_url, role, oauth=oauth, db=session,
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
# persist both records and reload generated defaults
await session.commit()
@@ -127,7 +133,10 @@ class AuthsTable:
return created_user if credential and created_user else None
async def authenticate_user(
self, email: str, verify_password: callable, db: AsyncSession | None = None,
self,
email: str,
verify_password: callable,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Verify email + password credentials and return the matching user."""
log.info('authenticate_user: %s', email)
@@ -144,7 +153,9 @@ class AuthsTable:
return resolved
async def authenticate_user_by_api_key(
self, api_key: str, db: AsyncSession | None = None,
self,
api_key: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up the user that owns the given API key."""
log.info('authenticate_user_by_api_key')
@@ -163,17 +174,19 @@ class AuthsTable:
# single JOIN avoids N+1 — returns (Auth, User) tuple or None
async with get_async_db_context(db) as session:
joined_query = (
select(Auth, User)
.join(User, Auth.id == User.id)
.where(Auth.email == email, Auth.active.is_(True))
select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
)
match = (await session.execute(joined_query)).first()
if not match:
return
_, found_user = match
return UserModel.model_validate(found_user)
async def update_email_by_id(
self, user_id: str, email: str, db: AsyncSession | None = None,
self,
user_id: str,
email: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new email on the auth record and propagate to the user row."""
async with get_async_db_context(db) as session:
@@ -185,8 +198,12 @@ class AuthsTable:
await Users.update_user_by_id(user_id, {'email': email}, db=session)
return True
# --- password modification ---
async def update_user_password_by_id(
self, user_id: str, new_password: str, db: AsyncSession | None = None,
self,
user_id: str,
new_password: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new password hash for an existing user."""
async with get_async_db_context(db) as session:
@@ -198,7 +215,9 @@ class AuthsTable:
return True
async def delete_auth_by_id(
self, id: str, db: AsyncSession | None = None,
self,
id: str,
db: AsyncSession | None = None,
) -> bool:
"""Remove a user and their auth credential in one transaction."""
async with get_async_db_context(db) as session:
+18 -9
View File
@@ -6,6 +6,7 @@ import json
import logging
import time
import uuid
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.automations import AutomationRun
@@ -358,9 +359,7 @@ class ChatTable:
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
# Validate folder_id references — clear any that don't exist
folder_ids = {
f.folder_id for f in chat_import_forms if f.folder_id
}
folder_ids = {f.folder_id for f in chat_import_forms if f.folder_id}
existing = set()
for fid in folder_ids:
if await Folders.get_folder_by_id_and_user_id(fid, user_id, db=session):
@@ -402,7 +401,10 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in chats]
async def update_chat_by_id(
self, id: str, chat: dict, db: AsyncSession | None = None,
self,
id: str,
chat: dict,
db: AsyncSession | None = None,
) -> ChatModel | None:
"""Persist updated chat content, sanitizing null bytes."""
try: # load the chat record for in-place mutation
@@ -512,9 +514,7 @@ class ChatTable:
except Exception as e:
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
async def reconcile_messages_by_chat_id(
self, chat_id: str, user_id: str, messages: dict[str, dict]
) -> None:
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Sync ``chat_message`` rows with the committed JSON blob.
Upserts current messages via ``backfill_messages_by_chat_id``
@@ -692,9 +692,12 @@ class ChatTable:
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat) # return the updated original
# refresh helper
async def update_shared_chat_by_chat_id(
self, chat_id: str, db: AsyncSession | None = None,
self,
chat_id: str,
db: AsyncSession | None = None,
) -> ChatModel | None:
"""Refresh the shared snapshot with current chat content."""
from open_webui.models.shared_chats import SharedChats
@@ -953,9 +956,12 @@ class ChatTable:
)
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
# retrieve conversation
async def get_chat_by_id(
self, id: str, db: AsyncSession | None = None,
self,
id: str,
db: AsyncSession | None = None,
) -> ChatModel | None:
"""Fetch a chat by PK, auto-sanitizing null bytes on read."""
try:
@@ -1034,6 +1040,7 @@ class ChatTable:
result = await session.execute(select(Chat).order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
# list user conversations
async def get_chats_by_user_id(
self,
@@ -1082,6 +1089,7 @@ class ChatTable:
'total': total,
}
)
# list pinned chats
async def get_pinned_chats_by_user_id(
self, user_id: str, db: AsyncSession | None = None
@@ -1112,6 +1120,7 @@ class ChatTable:
select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc())
)
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
# search user conversations
async def get_chats_by_user_id_and_search_text(
self,
+7 -8
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import sanitize_metadata
@@ -46,6 +47,7 @@ class FileModel(BaseModel):
created_at: int | None # timestamp in epoch
updated_at: int | None # timestamp in epoch
# --- metadata structures ---
class FileMeta(BaseModel):
name: str | None = None
@@ -150,7 +152,9 @@ class FilesTable:
return None # insertion failed
async def get_file_by_id(
self, id: str, db: AsyncSession | None = None,
self,
id: str,
db: AsyncSession | None = None,
) -> FileModel | None:
"""Look up a file by its primary key."""
try:
@@ -409,9 +413,7 @@ class FilesTable:
# Subquery: file IDs already linked to this knowledge base
linked_ids = (
select(KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
.correlate(None)
select(KnowledgeFile.file_id).filter(KnowledgeFile.knowledge_id == knowledge_id).correlate(None)
)
stmt = (
@@ -424,10 +426,7 @@ class FilesTable:
.order_by(File.created_at.desc())
)
result = await db.execute(stmt)
return [
FileModelResponse.model_validate(f, from_attributes=True)
for f in result.scalars().all()
]
return [FileModelResponse.model_validate(f, from_attributes=True) for f in result.scalars().all()]
except Exception as e:
log.warning(f'Error fetching pending files for knowledge {knowledge_id}: {e}')
return []
+2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import UserModel, UserResponse, Users
@@ -52,6 +53,7 @@ class FunctionModel(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
# --- form / schema definitions ---
class FunctionWithValvesModel(BaseModel):
id: str
+1
View File
@@ -34,6 +34,7 @@ class MemoryModel(BaseModel):
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True) # allows ORM mapping
class MemoriesTable:
async def insert_new_memory(
self,
+2 -6
View File
@@ -51,8 +51,7 @@ class ModelMeta(BaseModel):
if v not in _warned_profile_urls:
_warned_profile_urls.add(v)
log.warning(
'Clearing invalid profile_image_url stored in DB '
'(likely a legacy SVG data-URI): %.80s…',
'Clearing invalid profile_image_url stored in DB (likely a legacy SVG data-URI): %.80s…',
v,
)
return None
@@ -74,7 +73,6 @@ class ModelMeta(BaseModel):
class Model(Base):
"""Workspace model entry — wraps an upstream LLM with custom params and metadata."""
__tablename__ = 'model'
@@ -195,9 +193,7 @@ class ModelsTable:
models: list[ModelModel] = []
for model in all_models:
try:
models.append(
await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db)
)
models.append(await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db))
except Exception as exc:
log.error('Skipping model %r during get_all_models due to error: %s', model.id, exc)
return models
+39 -18
View File
@@ -55,6 +55,8 @@ class PromptModel(BaseModel):
access_grants: list[AccessGrantModel] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
# --- form / schema definitions ---
# Forms
####################
@@ -107,26 +109,36 @@ class PromptsTable:
)
return PromptModel.model_validate(prompt_data)
async def insert_new_prompt(self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None) -> PromptModel | None:
async def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None
) -> PromptModel | None:
now = int(time.time())
prompt_id = str(uuid.uuid4())
async with get_async_db_context(db) as session:
try:
record = Prompt(
id=prompt_id, user_id=user_id,
command=form_data.command, name=form_data.name,
id=prompt_id,
user_id=user_id,
command=form_data.command,
name=form_data.name,
content=form_data.content,
data=form_data.data or {}, meta=form_data.meta or {},
tags=form_data.tags or [], is_active=True,
created_at=now, updated_at=now,
data=form_data.data or {},
meta=form_data.meta or {},
tags=form_data.tags or [],
is_active=True,
created_at=now,
updated_at=now,
)
session.add(record)
await session.commit()
await session.refresh(record) # populate generated defaults
await AccessGrants.set_access_grants(
'prompt', prompt_id, form_data.access_grants, db=session,
'prompt',
prompt_id,
form_data.access_grants,
db=session,
) # persist sharing rules
if not record: # shouldn't happen, but guard anyway
@@ -145,8 +157,10 @@ class PromptsTable:
}
history_entry = await PromptHistories.create_history_entry(
prompt_id=prompt_id, snapshot=snapshot,
user_id=user_id, parent_id=None,
prompt_id=prompt_id,
snapshot=snapshot,
user_id=user_id,
parent_id=None,
commit_message=form_data.commit_message or 'Initial version',
db=session,
) # creates the first version entry
@@ -178,9 +192,7 @@ class PromptsTable:
async def get_prompt_by_command(self, command: str, db: AsyncSession | None = None) -> PromptModel | None:
"""Look up a prompt by its unique slash-command string."""
async with get_async_db_context(db) as session:
match = (await session.execute(
select(Prompt).where(Prompt.command == command)
)).scalars().first()
match = (await session.execute(select(Prompt).where(Prompt.command == command))).scalars().first()
if match is None:
return
return await self._to_prompt_model(match, db=session)
@@ -190,9 +202,15 @@ class PromptsTable:
async def get_prompts(self, db: AsyncSession | None = None) -> list[PromptUserResponse]:
"""Return all active prompts ordered by most recently updated."""
async with get_async_db_context(db) as session:
active = (await session.execute(
select(Prompt).where(Prompt.is_active.is_(True)).order_by(Prompt.updated_at.desc())
)).scalars().all()
active = (
(
await session.execute(
select(Prompt).where(Prompt.is_active.is_(True)).order_by(Prompt.updated_at.desc())
)
)
.scalars()
.all()
)
user_ids = list(set(p.user_id for p in active))
prompt_ids = [p.id for p in active]
@@ -600,10 +618,13 @@ class PromptsTable:
return await self._to_prompt_model(prompt, db=session)
except Exception as e: # connection error
log.error(f"Failed to restore prompt version: {e}")
log.error(f'Failed to restore prompt version: {e}')
return None # restoration failed
async def toggle_prompt_active(
self, prompt_id: str, db: AsyncSession | None = None,
self,
prompt_id: str,
db: AsyncSession | None = None,
) -> PromptModel | None:
"""Flip the is_active flag on a prompt."""
if not prompt_id:
@@ -654,7 +675,7 @@ class PromptsTable:
return True
return False
except Exception as err:
log.error(f"Failed to delete prompt: {err}")
log.error(f'Failed to delete prompt: {err}')
return False # deletion failed
async def get_tags(self, db: AsyncSession | None = None) -> list[str]:
+6 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import time
import uuid
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
@@ -37,6 +38,7 @@ class TagModel(BaseModel):
meta: dict | None = None
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
# --- tag schema forms ---
# Forms
####################
@@ -49,7 +51,10 @@ class TagChatIdForm(BaseModel):
class TagTable:
async def insert_new_tag(
self, name: str, user_id: str, db: AsyncSession | None = None,
self,
name: str,
user_id: str,
db: AsyncSession | None = None,
) -> TagModel | None:
"""Create a new tag, deriving the id from the name."""
async with get_async_db_context(db) as db:
+7 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
@@ -50,6 +51,7 @@ class ToolModel(BaseModel):
model_config = ConfigDict(from_attributes=True) # enables ORM mapping
# --- tool request forms ---
# Forms
####################
@@ -138,7 +140,9 @@ class ToolsTable:
return None # creation failed
async def get_tool_by_id(
self, id: str, db: AsyncSession | None = None,
self,
id: str,
db: AsyncSession | None = None,
) -> ToolModel | None:
"""Fetch a single tool by primary key, including access grants."""
try: # single PK lookup + access grants
@@ -150,18 +154,14 @@ class ToolsTable:
except Exception:
return None
async def get_tools_by_ids(
self, tool_ids: list[str], db: AsyncSession | None = None
) -> dict[str, ToolModel]:
async def get_tools_by_ids(self, tool_ids: list[str], db: AsyncSession | None = None) -> dict[str, ToolModel]:
"""Batch-fetch multiple tools by ID, returning a dict keyed by tool ID."""
if not tool_ids:
return {}
async with get_async_db_context(db) as db:
result = await db.execute(select(Tool).where(Tool.id.in_(tool_ids)))
tools = result.scalars().all()
grants_map = await AccessGrants.get_grants_by_resources(
'tool', [tool.id for tool in tools], db=db
)
grants_map = await AccessGrants.get_grants_by_resources('tool', [tool.id for tool in tools], db=db)
return {
tool.id: await self._to_tool_model(tool, access_grants=grants_map.get(tool.id, []), db=db)
for tool in tools
+36 -21
View File
@@ -46,11 +46,11 @@ class UserSettings(BaseModel):
class User(Base): # identity & profile
"""One row per registered account — profile, role, and settings."""
__tablename__: str = 'user' # Identity & Credentials
__tablename__: str = 'user' # Identity & Credentials
id = Column(String, primary_key=True, unique=True) # unique user id
email = Column(String, unique=True) # user email address
username = Column(String(50), nullable=True) # custom handle
role = Column(String, default="pending") # permissions role
role = Column(String, default='pending') # permissions role
name = Column(String, nullable=False) # display name
# Profile
@@ -117,20 +117,16 @@ class UserModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
)
# validation schema logic
# --- model validators ---
@model_validator(mode='after')
def _ensure_profile_image(self) -> 'UserModel':
"""Assign a generated avatar when no profile image is provided."""
self.profile_image_url = (
self.profile_image_url
or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
)
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
return self
class UserStatusModel(UserModel):
is_active: bool = False
@@ -303,31 +299,37 @@ class UsersTable:
await session.commit()
await session.refresh(result)
return user if result else None
# database read methods
# --- read / lookup operations ---
async def get_user_by_id(
self, id: str, db: AsyncSession | None = None,
self,
id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Fetch a single user by primary key."""
async with get_async_db_context(db) as session:
user = await session.get(User, id)
return UserModel.model_validate(user) if user else None
# api key auth helper
async def get_user_by_api_key(
self, api_key: str, db: AsyncSession | None = None,
self,
api_key: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Resolve a user from their API key via a JOIN on the api_key table."""
async with get_async_db_context(db) as session:
result = await session.execute(
select(User)
.join(ApiKey, User.id == ApiKey.user_id)
.where(ApiKey.key == api_key),
select(User).join(ApiKey, User.id == ApiKey.user_id).where(ApiKey.key == api_key),
)
user = result.scalars().first()
return UserModel.model_validate(user) if user else None
async def get_user_by_email(
self, email: str, db: AsyncSession | None = None,
self,
email: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Case-insensitive email lookup using SQL lower()."""
async with get_async_db_context(db) as session:
@@ -342,7 +344,10 @@ class UsersTable:
# --- oauth & integrations ---
async def get_user_by_oauth_sub(
self, provider: str, sub: str, db: AsyncSession | None = None,
self,
provider: str,
sub: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by OAuth provider + subject claim (dialect-aware JSON filter)."""
async with get_async_db_context(db) as session:
@@ -358,7 +363,10 @@ class UsersTable:
return UserModel.model_validate(row) if row else None
async def get_user_by_scim_external_id(
self, provider: str, external_id: str, db: AsyncSession | None = None,
self,
provider: str,
external_id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by SCIM provider + external ID (dialect-aware JSON filter)."""
async with get_async_db_context(db) as session:
@@ -373,10 +381,12 @@ class UsersTable:
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
async def get_users(
self, filter: dict | None = None, skip: int | None = None,
limit: int | None = None, db: AsyncSession | None = None,
self,
filter: dict | None = None,
skip: int | None = None,
limit: int | None = None,
db: AsyncSession | None = None,
) -> dict:
"""Paginated user listing with optional filters for role, group, and channel."""
async with get_async_db_context(db) as session:
@@ -531,11 +541,13 @@ class UsersTable:
result = await session.execute(select(User).filter(User.id.in_(user_ids)))
users = result.scalars().all()
return [UserModel.model_validate(user) for user in users]
# count registered accounts
async def get_num_users(self, db: AsyncSession | None = None) -> int | None:
async with get_async_db_context(db) as session:
result = await session.execute(select(func.count()).select_from(User))
return result.scalar()
# check user existence
async def has_users(self, db: AsyncSession | None = None) -> bool:
async with get_async_db_context(db) as session:
@@ -589,7 +601,10 @@ class UsersTable:
return UserModel.model_validate(user)
async def update_user_profile_image_url_by_id(
self, id: str, profile_image_url: str, db: AsyncSession | None = None,
self,
id: str,
profile_image_url: str,
db: AsyncSession | None = None,
) -> UserModel | None:
async with get_async_db_context(db) as session:
user = await session.get(User, id)
@@ -650,6 +665,7 @@ class UsersTable:
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
# settings update helper
async def update_user_settings_by_id(
self, id: str, updated: dict, db: AsyncSession | None = None
@@ -745,4 +761,3 @@ class UsersTable:
Users = UsersTable() # singleton user repository
+32 -33
View File
@@ -273,18 +273,18 @@ class Loader:
4. Falling back to latin-1 (always valid, ftfy fixes mojibake later).
"""
try:
with open(file_path, "rb") as f:
with open(file_path, 'rb') as f:
raw = f.read()
except OSError:
return "utf-8"
return 'utf-8'
if not raw:
return "utf-8"
return 'utf-8'
# Fast path: most files are UTF-8
try:
raw.decode("utf-8")
return "utf-8"
raw.decode('utf-8')
return 'utf-8'
except UnicodeDecodeError:
pass
@@ -292,24 +292,24 @@ class Loader:
import chardet
detected = chardet.detect(raw)
detected_enc = (detected.get("encoding") or "").lower().replace("-", "").replace("_", "")
detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
# Map chardet's detected encoding to the correct superset codec.
# chardet often reports GB2312 for content that is actually GB18030;
# GB18030 is a strict superset of both GB2312 and GBK.
_ENC_FAMILY = {
"gb2312": "gb18030",
"gb18030": "gb18030",
"gbk": "gb18030",
"big5": "big5",
"euckr": "euc-kr",
"eucjp": "euc-jp",
"iso2022jp": "euc-jp",
"shiftjis": "shift_jis",
'gb2312': 'gb18030',
'gb18030': 'gb18030',
'gbk': 'gb18030',
'big5': 'big5',
'euckr': 'euc-kr',
'eucjp': 'euc-jp',
'iso2022jp': 'euc-jp',
'shiftjis': 'shift_jis',
}
# Build priority list: chardet-hinted codec first, then remaining CJK
base_order = ["gb18030", "big5", "euc-kr", "euc-jp"]
base_order = ['gb18030', 'big5', 'euc-kr', 'euc-jp']
hinted = _ENC_FAMILY.get(detected_enc)
if hinted and hinted in base_order:
ordered = [hinted] + [e for e in base_order if e != hinted]
@@ -321,10 +321,10 @@ class Loader:
text = raw.decode(enc)
if text.strip() and self._has_cjk_characters(text):
log.info(
"Detected encoding %s for %s (chardet guessed %s)",
'Detected encoding %s for %s (chardet guessed %s)',
enc,
file_path,
detected.get("encoding"),
detected.get('encoding'),
)
return enc
except (UnicodeDecodeError, LookupError):
@@ -332,12 +332,12 @@ class Loader:
# If chardet gave a non-CJK answer that isn't in our family map,
# try it directly — it might be a valid Western encoding.
chardet_encoding = detected.get("encoding")
chardet_encoding = detected.get('encoding')
if chardet_encoding:
try:
raw.decode(chardet_encoding)
log.info(
"Using chardet-detected encoding %s for %s",
'Using chardet-detected encoding %s for %s',
chardet_encoding,
file_path,
)
@@ -348,8 +348,8 @@ class Loader:
# latin-1 is the ultimate fallback: every byte 0x00–0xFF is valid.
# ftfy.fix_text() (applied downstream) repairs most mojibake that
# results from treating Windows-1252 content as Latin-1.
log.info("Falling back to latin-1 encoding for %s", file_path)
return "latin-1"
log.info('Falling back to latin-1 encoding for %s', file_path)
return 'latin-1'
@staticmethod
def _has_cjk_characters(text: str, threshold: float = 0.05) -> bool:
@@ -371,17 +371,17 @@ class Loader:
total += 1
cp = ord(ch)
if (
0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= cp <= 0x4DBF # CJK Extension A
or 0x20000 <= cp <= 0x2A6DF # CJK Extension B
or 0x2A700 <= cp <= 0x2B73F # CJK Extension C
or 0x2B740 <= cp <= 0x2B81F # CJK Extension D
or 0xF900 <= cp <= 0xFAFF # CJK Compatibility Ideographs
or 0x3000 <= cp <= 0x303F # CJK Symbols and Punctuation
or 0x3040 <= cp <= 0x309F # Hiragana
or 0x30A0 <= cp <= 0x30FF # Katakana
or 0xAC00 <= cp <= 0xD7AF # Hangul Syllables
or 0xFF00 <= cp <= 0xFFEF # Halfwidth and Fullwidth Forms
0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= cp <= 0x4DBF # CJK Extension A
or 0x20000 <= cp <= 0x2A6DF # CJK Extension B
or 0x2A700 <= cp <= 0x2B73F # CJK Extension C
or 0x2B740 <= cp <= 0x2B81F # CJK Extension D
or 0xF900 <= cp <= 0xFAFF # CJK Compatibility Ideographs
or 0x3000 <= cp <= 0x303F # CJK Symbols and Punctuation
or 0x3040 <= cp <= 0x309F # Hiragana
or 0x30A0 <= cp <= 0x30FF # Katakana
or 0xAC00 <= cp <= 0xD7AF # Hangul Syllables
or 0xFF00 <= cp <= 0xFFEF # Halfwidth and Fullwidth Forms
):
cjk_count += 1
@@ -646,4 +646,3 @@ class Loader:
loader = TextLoader(file_path, encoding=self._detect_text_encoding(file_path))
return loader
+5 -10
View File
@@ -933,6 +933,7 @@ def get_embedding_function(
'SentenceTransformer model name, or configure an external '
'RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).'
)
# Sentence transformers: CPU-bound sync operation
async def async_embedding_function(query, prefix=None, user=None):
return await asyncio.to_thread(
@@ -1108,8 +1109,7 @@ async def filter_accessible_collections(
rejected = collection_names - safe_names
if rejected:
log.warning(
'filter_accessible_collections: rejected %d collection name(s) '
'with unsafe characters (user_id=%s)',
'filter_accessible_collections: rejected %d collection name(s) with unsafe characters (user_id=%s)',
len(rejected),
getattr(user, 'id', '<unknown>'),
)
@@ -1363,10 +1363,7 @@ async def get_sources_from_items(
files = await Knowledges.get_files_by_id(knowledge_base.id)
owned_names = {f'file-{f.id}' for f in files}
owned_names.add(knowledge_base.id)
valid_names = [
n for n in (item.get('collection_names') or [])
if n in owned_names
]
valid_names = [n for n in (item.get('collection_names') or []) if n in owned_names]
collection_names = valid_names if valid_names else [knowledge_base.id]
else:
collection_names.append(item['id'])
@@ -1382,8 +1379,7 @@ async def get_sources_from_items(
collection_names.append(item['collection_name'])
else:
log.debug(
"get_sources_from_items: ignoring untrusted direct "
"collection_name '%s' on item without type",
"get_sources_from_items: ignoring untrusted direct collection_name '%s' on item without type",
item.get('collection_name'),
)
elif item.get('collection_names'):
@@ -1391,8 +1387,7 @@ async def get_sources_from_items(
collection_names.extend(item['collection_names'])
else:
log.debug(
"get_sources_from_items: ignoring untrusted direct "
"collection_names on item without type",
'get_sources_from_items: ignoring untrusted direct collection_names on item without type',
)
# If query_result is None
@@ -284,9 +284,7 @@ class MilvusClient(VectorDBBase):
elif isinstance(value, (int, float)):
expr.append(f"metadata['{key}'] == {value}")
else:
raise TypeError(
f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}'
)
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
iterator = collection.query_iterator(
expr=' and '.join(expr),
@@ -56,8 +56,7 @@ def _import_glide():
)
except ImportError as e:
raise ImportError(
'valkey-glide-sync is required when VECTOR_DB=valkey. '
'Install it with: pip install valkey-glide-sync==2.3.1'
'valkey-glide-sync is required when VECTOR_DB=valkey. Install it with: pip install valkey-glide-sync==2.3.1'
) from e
return {
'Batch': Batch,
@@ -193,7 +192,7 @@ class ValkeyClient(VectorDBBase):
addresses=[NodeAddress(host=host, port=port)],
database_id=db if db else None,
request_timeout=5000,
client_name="open_webui_vector_store_client",
client_name='open_webui_vector_store_client',
)
try:
self.client = GlideClient.create(config)
@@ -206,7 +205,7 @@ class ValkeyClient(VectorDBBase):
addresses=[NodeAddress(host=host, port=port)],
database_id=db if db else None,
request_timeout=10000, # 10s — HNSW indexing can take 1-4s per vector
client_name="open_webui_vector_store_batch_client",
client_name='open_webui_vector_store_batch_client',
)
try:
self.batch_client = GlideClient.create(batch_config)
+1 -3
View File
@@ -66,9 +66,7 @@ def main():
results = search_bing(
os.environ.get('BING_SEARCH_V7_SUBSCRIPTION_KEY', ''),
os.environ.get(
'BING_SEARCH_V7_ENDPOINT', 'https://api.bing.microsoft.com/v7.0/search'
),
os.environ.get('BING_SEARCH_V7_ENDPOINT', 'https://api.bing.microsoft.com/v7.0/search'),
args.locale,
args.query,
args.count,
+1 -3
View File
@@ -51,9 +51,7 @@ def search_linkup(
output_type = merged.get('outputType', 'sourcedAnswer')
search_results = (
json_response.get('sources', [])
if output_type == 'sourcedAnswer'
else json_response.get('results', [])
json_response.get('sources', []) if output_type == 'sourcedAnswer' else json_response.get('results', [])
)
if filter_list:
+85 -56
View File
@@ -72,7 +72,6 @@ SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech'
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
def is_audio_conversion_required(file_path):
"""
Check if the given audio file needs conversion to mp3.
@@ -307,8 +306,6 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
else:
request.app.state.faster_whisper_model = None
return {
'tts': {
'ENGINE': request.app.state.config.TTS_ENGINE,
@@ -377,7 +374,10 @@ async def _raise_tts_error(exc: Exception, r=None) -> None:
async def _write_tts_cache(
file_path: Path, audio: bytes, body_path: Path, payload: dict,
file_path: Path,
audio: bytes,
body_path: Path,
payload: dict,
) -> None:
"""Persist audio + request metadata to the speech cache."""
async with aiofiles.open(file_path, 'wb') as f:
@@ -405,16 +405,16 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
session = await get_session()
r = await session.post(
url=f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech',
json=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL,
json=payload,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
audio_data = await r.read()
content_type = r.headers.get('Content-Type', 'audio/mpeg')
if not await asyncio.to_thread(
transcode_audio_to_mp3, audio_data, content_type, file_path
):
if not await asyncio.to_thread(transcode_audio_to_mp3, audio_data, content_type, file_path):
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_data)
@@ -595,9 +595,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(
body
+ str(engine).encode('utf-8')
+ str(request.app.state.config.TTS_MODEL).encode('utf-8')
body + str(engine).encode('utf-8') + str(request.app.state.config.TTS_MODEL).encode('utf-8')
).hexdigest()
file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3')
@@ -628,8 +626,11 @@ async def _transcribe_whisper(request, file_path, languages, file_dir, id):
def _run():
segments, info = model.transcribe(
file_path, beam_size=5, vad_filter=WHISPER_VAD_FILTER,
language=languages[0], multilingual=WHISPER_MULTILINGUAL,
file_path,
beam_size=5,
vad_filter=WHISPER_VAD_FILTER,
language=languages[0],
multilingual=WHISPER_MULTILINGUAL,
)
log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability))
return ''.join([segment.text for segment in list(segments)])
@@ -665,7 +666,9 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
r = await session.post(
url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions',
headers=headers, data=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL,
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if r.status == 200:
break
@@ -724,11 +727,7 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
# Parse the Deepgram response structure
try:
transcript = (
body['results']['channels'][0]['alternatives'][0]
.get('transcript', '')
.strip()
)
transcript = body['results']['channels'][0]['alternatives'][0].get('transcript', '').strip()
except (KeyError, IndexError) as exc:
log.error(f'Malformed Deepgram response: {exc}')
raise Exception('Failed to parse Deepgram response') from exc
@@ -744,7 +743,11 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
if r is not None:
try:
res = await r.json()
msg = res.get('error', {}).get('message', '') if isinstance(res.get('error'), dict) else str(res.get('error', ''))
msg = (
res.get('error', {}).get('message', '')
if isinstance(res.get('error'), dict)
else str(res.get('error', ''))
)
if msg:
detail = f'External: {msg}'
except Exception:
@@ -752,7 +755,6 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
raise Exception(detail)
async def _transcribe_azure(request, file_path, filename, file_dir, id):
"""Transcribe audio via Azure Cognitive Services batch transcription."""
if not os.path.isfile(file_path):
@@ -762,7 +764,7 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
if audio_size > AZURE_MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File size ({audio_size // (1024*1024)}MB) exceeds Azure limit of {AZURE_MAX_FILE_SIZE_MB}MB',
detail=f'File size ({audio_size // (1024 * 1024)}MB) exceeds Azure limit of {AZURE_MAX_FILE_SIZE_MB}MB',
)
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
@@ -773,11 +775,23 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
# Default to a broad set of locales when none are configured
if len(locale_str) < 2:
locale_str = ','.join([
'en-US', 'es-ES', 'es-MX', 'fr-FR', 'hi-IN',
'it-IT', 'de-DE', 'en-GB', 'en-IN', 'ja-JP',
'ko-KR', 'pt-BR', 'zh-CN',
])
locale_str = ','.join(
[
'en-US',
'es-ES',
'es-MX',
'fr-FR',
'hi-IN',
'it-IT',
'de-DE',
'en-GB',
'en-IN',
'ja-JP',
'ko-KR',
'pt-BR',
'zh-CN',
]
)
if not api_key or not region:
raise HTTPException(status_code=400, detail='Azure API key and region are required for Azure STT')
@@ -785,7 +799,8 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
# Build the transcription definition payload
definition = json.dumps(
{'locales': locale_str.split(','), 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}}
if locale_str else {}
if locale_str
else {}
)
endpoint = (
base_url or f'https://{region}.api.cognitive.microsoft.com'
@@ -799,7 +814,8 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
try:
session = await get_session()
r = await session.post(
url=endpoint, data=form_data,
url=endpoint,
data=form_data,
headers={'Ocp-Apim-Subscription-Key': api_key},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -833,8 +849,10 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
if 'code' in res and 'message' in res:
azure_code = res.get('innerError', {}).get('code', res['code'])
user_facing_codes = {
'EmptyAudioFile', 'AudioLengthLimitExceeded',
'NoLanguageIdentified', 'MultipleLanguagesIdentified',
'EmptyAudioFile',
'AudioLengthLimitExceeded',
'NoLanguageIdentified',
'MultipleLanguagesIdentified',
}
if azure_code in user_facing_codes:
detail = res['message']
@@ -851,7 +869,6 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
)
async def transcription_handler(request, file_path, metadata, user=None):
filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path)
@@ -897,8 +914,7 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
try:
model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest'
log.info(
f'Mistral STT - model: {model}, '
f'method: {"chat_completions" if use_chat_completions else "transcriptions"}'
f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}'
)
session = await get_session()
@@ -932,17 +948,20 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
payload = {
'model': model,
'messages': [{
'role': 'user',
'content': [
{'type': 'input_audio', 'input_audio': audio_base64},
{'type': 'text', 'text': text_instruction},
],
}],
'messages': [
{
'role': 'user',
'content': [
{'type': 'input_audio', 'input_audio': audio_base64},
{'type': 'text', 'text': text_instruction},
],
}
],
}
r = await session.post(
url=f'{api_base_url}/chat/completions', json=payload,
url=f'{api_base_url}/chat/completions',
json=payload,
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -969,7 +988,8 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions', data=form_data,
url=f'{api_base_url}/audio/transcriptions',
data=form_data,
headers={'Authorization': f'Bearer {api_key}'},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -1041,10 +1061,7 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
results = []
try:
tasks = [
transcription_handler(request, chunk_path, metadata, user)
for chunk_path in chunk_paths
]
tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
for coro in asyncio.as_completed(tasks):
try:
results.append(await coro)
@@ -1226,7 +1243,8 @@ async def get_available_models(request: Request) -> list[dict]:
try:
async with session.get(
f'{base_url}/audio/models',
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -1236,7 +1254,8 @@ async def get_available_models(request: Request) -> list[dict]:
try:
async with session.get(
f'{base_url}/models',
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -1256,7 +1275,8 @@ async def get_available_models(request: Request) -> list[dict]:
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
models = await resp.json()
@@ -1274,9 +1294,14 @@ async def get_available_models(request: Request) -> list[dict]:
async def get_models(request: Request, user=Depends(get_verified_user)):
return {'models': await get_available_models(request)}
_OPENAI_DEFAULT_VOICES = {
'alloy': 'alloy', 'echo': 'echo', 'fable': 'fable',
'onyx': 'onyx', 'nova': 'nova', 'shimmer': 'shimmer',
'alloy': 'alloy',
'echo': 'echo',
'fable': 'fable',
'onyx': 'onyx',
'nova': 'nova',
'shimmer': 'shimmer',
}
@@ -1292,7 +1317,8 @@ async def get_available_voices(request) -> dict:
session = await get_session()
async with session.get(
f'{base_url}/audio/voices',
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -1311,7 +1337,8 @@ async def get_available_voices(request) -> dict:
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
voices_data = await resp.json()
@@ -1330,7 +1357,8 @@ async def get_available_voices(request) -> dict:
async with session.get(
url,
headers={'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY},
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
voices = await resp.json()
@@ -1348,7 +1376,8 @@ async def get_available_voices(request) -> dict:
async with session.get(
f'{api_base_url}/audio/voices',
headers={'Authorization': f'Bearer {api_key}'},
ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=_timeout,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
resp.raise_for_status()
voices_data = await resp.json()
+7 -3
View File
@@ -514,9 +514,13 @@ async def delete_all_user_chats(
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_user_id(
user_id: str, page: int | None = None, query: str | None = None,
order_by: str | None = None, direction: str | None = None,
user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session),
user_id: str,
page: int | None = None,
query: str | None = None,
order_by: str | None = None,
direction: str | None = None,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
"""List chat summaries for a given user (admin-only endpoint)."""
if not ENABLE_ADMIN_CHAT_ACCESS:
+3 -10
View File
@@ -123,9 +123,7 @@ async def process_uploaded_file(
if _is_text_file(file_path):
content_type = 'text/plain'
stt_supported = getattr(
request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []
)
stt_supported = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
if content_type and strict_match_mime_type(stt_supported, content_type):
# Audio / STT-supported files → transcribe then index
@@ -161,17 +159,12 @@ async def process_uploaded_file(
db=db_session,
)
else:
raise Exception(
f'File type {content_type} is not supported for processing'
)
raise Exception(f'File type {content_type} is not supported for processing')
else:
# Documents, or any file when an external engine is configured
if not content_type:
log.info(
f'File type {file.content_type} is not provided, '
'but trying to process anyway'
)
log.info(f'File type {file.content_type} is not provided, but trying to process anyway')
await process_file(
request,
ProcessFileForm(file_id=file_item.id),
+3 -16
View File
@@ -336,29 +336,16 @@ async def preview_group_access(
return {
'group': {'id': group.id, 'name': group.name},
'models': {
'items': [
{'id': m.id, 'name': m.name}
for m in active_models
if m.id in accessible_model_ids
],
'items': [{'id': m.id, 'name': m.name} for m in active_models if m.id in accessible_model_ids],
'total': len(active_models),
},
'knowledge': {
'items': [
{'id': k.id, 'name': k.name}
for k in all_knowledge
if k.id in accessible_knowledge_ids
],
'items': [{'id': k.id, 'name': k.name} for k in all_knowledge if k.id in accessible_knowledge_ids],
'total': len(all_knowledge),
},
'tools': {
'items': [
{'id': t.id, 'name': t.name}
for t in all_tools
if t.id in accessible_tool_ids
],
'items': [{'id': t.id, 'name': t.name} for t in all_tools if t.id in accessible_tool_ids],
'total': len(all_tools),
},
'permissions': group.permissions or {},
}
+6 -4
View File
@@ -447,6 +447,7 @@ def _is_same_origin(url: str, base_url: str) -> bool:
and comparing the three origin components eliminates those
attack vectors.
"""
def _default_port(scheme: str) -> int:
return 443 if scheme == 'https' else 80
@@ -455,8 +456,7 @@ def _is_same_origin(url: str, base_url: str) -> bool:
return (
parsed.scheme == trusted.scheme
and parsed.hostname == trusted.hostname
and (parsed.port or _default_port(parsed.scheme))
== (trusted.port or _default_port(trusted.scheme))
and (parsed.port or _default_port(parsed.scheme)) == (trusted.port or _default_port(trusted.scheme))
)
@@ -743,7 +743,8 @@ async def image_generations(
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
image_data, content_type = await get_image_data(
image['url'], headers,
image['url'],
headers,
trusted_base_url=request.app.state.config.COMFYUI_BASE_URL,
)
_, url = await upload_image(
@@ -1102,7 +1103,8 @@ async def image_edits(
headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'}
image_data, content_type = await get_image_data(
image_url, headers,
image_url,
headers,
trusted_base_url=request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
)
_, url = await upload_image(
+5 -2
View File
@@ -1336,8 +1336,11 @@ async def add_files_to_knowledge_batch(
dir_map = {form.file_id: form.directory_id for form in new_entries}
for file_id in successful_file_ids:
await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=file_id, user_id=user.id,
directory_id=dir_map.get(file_id), db=db,
knowledge_id=id,
file_id=file_id,
user_id=user.id,
directory_id=dir_map.get(file_id),
db=db,
)
# If there were any errors, include them in the response
+3 -1
View File
@@ -63,7 +63,9 @@ class MemoryUpdateModel(BaseModel):
@router.post('/add', response_model=MemoryModel | None)
async def add_memory(
request: Request, form_data: AddMemoryForm, user=Depends(get_verified_user),
request: Request,
form_data: AddMemoryForm,
user=Depends(get_verified_user),
):
"""Persist a new memory and embed it into the user's vector collection.
+19 -11
View File
@@ -220,8 +220,10 @@ async def get_model_tags(user=Depends(get_verified_user), db: AsyncSession = Dep
@router.post('/create', response_model=ModelModel | None)
async def create_new_model(
request: Request, form_data: ModelForm,
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
request: Request,
form_data: ModelForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Create a new workspace model entry."""
if user.role != 'admin' and not await has_permission(
@@ -248,7 +250,8 @@ async def create_new_model(
else:
await _verify_knowledge_file_access(
getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
user, db,
user,
db,
)
form_data.access_grants = await filter_allowed_access_grants(
@@ -361,13 +364,14 @@ async def import_models(
try:
await _verify_knowledge_file_access(
(model_data.get('meta') or {}).get('knowledge'),
user, db,
user,
db,
)
except HTTPException:
log.warning(
'import_models: user %s skipped model %s '
'(knowledge file access denied)',
user.id, model_id,
'import_models: user %s skipped model %s (knowledge file access denied)',
user.id,
model_id,
)
continue
@@ -634,14 +638,17 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Async
@router.post('/model/update', response_model=ModelModel | None)
async def update_model_by_id(
request: Request, form_data: ModelForm,
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
request: Request,
form_data: ModelForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Update a workspace model's configuration."""
model = await Models.get_model_by_id(form_data.id, db=db)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND,
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
@@ -662,7 +669,8 @@ async def update_model_by_id(
await _verify_knowledge_file_access(
getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
user, db,
user,
db,
)
form_data.access_grants = await filter_allowed_access_grants(
+19 -36
View File
@@ -338,9 +338,7 @@ async def get_all_models(request: Request, user: UserModel | None = None):
allowed_model_ids = api_config.get('model_ids', [])
if allowed_model_ids and 'models' in response:
response['models'] = [
m for m in response['models'] if m['model'] in allowed_model_ids
]
response['models'] = [m for m in response['models'] if m['model'] in allowed_model_ids]
for m in response.get('models', []):
if prefix_id:
@@ -350,11 +348,7 @@ async def get_all_models(request: Request, user: UserModel | None = None):
if connection_type:
m['connection_type'] = connection_type
models_dict = {
'models': merge_models_lists(
r.get('models', []) if r else None for r in responses
)
}
models_dict = {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)}
# Annotate with expiry info from loaded-model state
try:
@@ -386,7 +380,8 @@ async def get_filtered_models(models, user, db=None):
db=db,
)
return [
m for m in models.get('models', [])
m
for m in models.get('models', [])
if (mi := model_infos.get(m['model'])) and (user.id == mi.user_id or mi.id in accessible_ids)
]
@@ -439,19 +434,13 @@ async def get_ollama_loaded_models(
for idx, response in enumerate(responses):
if not response:
continue
api_config = _resolve_api_config(
request.app.state.config, idx, request.app.state.config.OLLAMA_BASE_URLS[idx]
)
api_config = _resolve_api_config(request.app.state.config, idx, request.app.state.config.OLLAMA_BASE_URLS[idx])
prefix_id = api_config.get('prefix_id')
if prefix_id:
for m in response.get('models', []):
m['model'] = f'{prefix_id}.{m["model"]}'
return {
'models': merge_models_lists(
r.get('models', []) if r else None for r in responses
)
}
return {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)}
@router.get('/api/version')
@@ -472,7 +461,8 @@ async def get_ollama_versions(
tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
if api_config.get('enable', True):
tasks.append(send_get_request(f'{url}/api/version', api_config.get('key')))
@@ -799,7 +789,8 @@ async def embed(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
@@ -852,7 +843,8 @@ async def embeddings(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
@@ -910,7 +902,8 @@ async def generate_completion(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
prefix_id = api_config.get('prefix_id')
@@ -1027,9 +1020,7 @@ async def generate_chat_completion(
if model_info is not None:
if model_info.base_model_id:
base_model_id = (
request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id
)
base_model_id = request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id
payload['model'] = base_model_id
params = model_info.params.model_dump()
@@ -1331,19 +1322,12 @@ async def get_openai_models(
raw_models = model_list.get('models', [])
now_ts = int(time.time())
models = [
{'id': m['model'], 'object': 'model', 'created': now_ts, 'owned_by': 'openai'}
for m in raw_models
]
models = [{'id': m['model'], 'object': 'model', 'created': now_ts, 'owned_by': 'openai'} for m in raw_models]
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
model_ids = [m['id'] for m in models]
model_infos = {
mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)
}
user_group_ids = {
g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)
}
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',
@@ -1353,8 +1337,7 @@ async def get_openai_models(
db=db,
)
models = [
m for m in models
if (mi := model_infos.get(m['id'])) and (user.id == mi.user_id or mi.id in accessible_ids)
m for m in models if (mi := model_infos.get(m['id'])) and (user.id == mi.user_id or mi.id in accessible_ids)
]
return {'data': models, 'object': 'list'}
+5 -2
View File
@@ -239,8 +239,11 @@ async def get_prompt_by_id(
@router.post('/id/{prompt_id}/update', response_model=PromptModel | None)
async def update_prompt_by_id(
request: Request, prompt_id: str, form_data: PromptForm,
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
request: Request,
prompt_id: str,
form_data: PromptForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Update a prompt's content, creating a new history entry if changed."""
prompt = await Prompts.get_prompt_by_id(prompt_id, db=db)
+1 -2
View File
@@ -1314,7 +1314,7 @@ def merge_docs_to_target_size(
prev = result[-1]
if not can_merge_chunks(prev, chunk):
return False
merged = f"{prev.page_content}\n\n{content}"
merged = f'{prev.page_content}\n\n{content}'
if measure(merged) > max_size:
return False
result[-1] = Document(page_content=merged, metadata={**prev.metadata})
@@ -2211,7 +2211,6 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
raise Exception('No search engine API key found in environment variables')
@router.post('/process/web/search')
async def process_web_search(request: Request, form_data: SearchForm, user=Depends(get_verified_user)):
if not request.app.state.config.ENABLE_WEB_SEARCH:
+11 -5
View File
@@ -324,8 +324,10 @@ async def export_tools(
@router.post('/create', response_model=ToolResponse | None)
async def create_new_tools(
request: Request, form_data: ToolForm,
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
request: Request,
form_data: ToolForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Create a new tool from user-supplied Python source code."""
if user.role != 'admin' and not (
@@ -448,14 +450,18 @@ async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
@router.post('/id/{id}/update', response_model=ToolModel | None)
async def update_tools_by_id(
request: Request, id: str, form_data: ToolForm,
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
request: Request,
id: str,
form_data: ToolForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Update an existing tool's source code and metadata."""
tools = await Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND,
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Is the user the original creator, in a group with write access, or an admin
+9 -17
View File
@@ -384,7 +384,9 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
@router.post('/user/info/update', response_model=dict | None)
async def update_user_info_by_session_user( # PATCH-style merge
form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session),
form_data: dict,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Merge caller-supplied fields into the current user's info dict.
@@ -401,6 +403,7 @@ async def update_user_info_by_session_user( # PATCH-style merge
)
return updated.info
############################
# GetUserById
############################
@@ -538,7 +541,8 @@ async def get_user_active_status_by_id(
@router.post('/{user_id}/update', response_model=UserModel | None)
async def update_user_by_id(
user_id: str, form_data: UserUpdateForm,
user_id: str,
form_data: UserUpdateForm,
session_user: UserModel = Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -743,27 +747,15 @@ async def get_user_preview(
'user': {'id': target_user.id, 'name': target_user.name},
'groups': [{'id': g.id, 'name': g.name} for g in user_groups],
'models': {
'items': [
{'id': m.id, 'name': m.name}
for m in active_models
if m.id in accessible_model_ids
],
'items': [{'id': m.id, 'name': m.name} for m in active_models if m.id in accessible_model_ids],
'total': len(active_models),
},
'knowledge': {
'items': [
{'id': k.id, 'name': k.name}
for k in all_knowledge
if k.id in accessible_knowledge_ids
],
'items': [{'id': k.id, 'name': k.name} for k in all_knowledge if k.id in accessible_knowledge_ids],
'total': len(all_knowledge),
},
'tools': {
'items': [
{'id': t.id, 'name': t.name}
for t in all_tools
if t.id in accessible_tool_ids
],
'items': [{'id': t.id, 'name': t.name} for t in all_tools if t.id in accessible_tool_ids],
'total': len(all_tools),
},
}
+4 -4
View File
@@ -9,7 +9,9 @@ import uuid
import pycrdt as Y
from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_KEY_PREFIX
YDOC_KEY_PREFIX = f"{REDIS_KEY_PREFIX}:ydoc:documents"
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
class RedisLock:
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
@@ -105,9 +107,7 @@ class RedisDict:
# this process wrote. The check is per-instance (not distributed), but
# still eliminates the majority of redundant writes because each pod
# typically produces the same model list on consecutive refreshes.
signature = hashlib.sha256(
json.dumps(serialized, sort_keys=True).encode()
).hexdigest()
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
if signature == self._last_signature:
return
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -6
View File
@@ -50,23 +50,25 @@ MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
async def _has_read_access_to_file(
file, user_id: str, user_role: str,
file,
user_id: str,
user_role: str,
model_knowledge: Optional[list[dict]] = None,
) -> bool:
"""Check if a user can read a file via ownership, admin role, model attachment, or access grants."""
if file.user_id == user_id or user_role == 'admin':
return True
if model_knowledge and any(
item.get('type') == 'file' and item.get('id') == file.id
for item in model_knowledge
):
if model_knowledge and any(item.get('type') == 'file' and item.get('id') == file.id for item in model_knowledge):
return True
from open_webui.utils.access_control.files import has_access_to_file
return await has_access_to_file(
file_id=file.id, access_type='read',
file_id=file.id,
access_type='read',
user=UserModel(**{'id': user_id, 'role': user_role}),
)
# =============================================================================
# TIME UTILITIES
# =============================================================================
+133 -93
View File
@@ -34,9 +34,16 @@ MAX_GREP_MATCHES = 50
def is_regex_pattern(pattern: str) -> bool:
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
return ('\|' in pattern or '.*' in pattern or '.+' in pattern
or '.?' in pattern or '\d' in pattern or '\w' in pattern
or '\s' in pattern or bool(re.search(r'\[.+\]', pattern)))
return (
'\|' in pattern
or '.*' in pattern
or '.+' in pattern
or '.?' in pattern
or '\d' in pattern
or '\w' in pattern
or '\s' in pattern
or bool(re.search(r'\[.+\]', pattern))
)
def normalize_regex(pattern: str) -> str:
@@ -44,8 +51,7 @@ def normalize_regex(pattern: str) -> str:
return pattern.replace('\\|', '|').replace('\|', '|')
def build_matcher(pattern: str, case_insensitive: bool = False,
use_regex: bool = False) -> tuple:
def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool = False) -> tuple:
"""Build a matcher function. Returns (match_fn, error_str_or_None)."""
if not use_regex and is_regex_pattern(pattern):
use_regex = True
@@ -157,6 +163,7 @@ async def _build_directory_tree(knowledge_id: str) -> dict:
# Compute full path for each directory
dir_id_to_path = {}
def _get_dir_path(dir_id):
if dir_id in dir_id_to_path:
return dir_id_to_path[dir_id]
@@ -165,7 +172,7 @@ async def _build_directory_tree(knowledge_id: str) -> dict:
return ''
if d['parent_id'] and d['parent_id'] in dir_map:
parent_path = _get_dir_path(d['parent_id'])
path = f"{parent_path}/{d['name']}" if parent_path else d['name']
path = f'{parent_path}/{d["name"]}' if parent_path else d['name']
else:
path = d['name']
dir_id_to_path[dir_id] = path
@@ -180,16 +187,20 @@ async def _build_directory_tree(knowledge_id: str) -> dict:
files = []
for file_model, directory_id in files_with_dirs:
if directory_id and directory_id in dir_id_to_path:
file_path = f"{dir_id_to_path[directory_id]}/{file_model.filename}"
file_path = f'{dir_id_to_path[directory_id]}/{file_model.filename}'
else:
file_path = file_model.filename
files.append({
'id': file_model.id, 'filename': file_model.filename,
'path': file_path, 'directory_id': directory_id,
'size': file_model.meta.get('size') if file_model.meta else None,
'type': file_model.meta.get('content_type') if file_model.meta else None,
'updated_at': file_model.updated_at,
})
files.append(
{
'id': file_model.id,
'filename': file_model.filename,
'path': file_path,
'directory_id': directory_id,
'size': file_model.meta.get('size') if file_model.meta else None,
'type': file_model.meta.get('content_type') if file_model.meta else None,
'updated_at': file_model.updated_at,
}
)
return {
'dirs': dir_map,
@@ -212,10 +223,7 @@ def _get_files_in_dir(tree: dict, dir_id: str | None) -> list[dict]:
def _get_subdirs(tree: dict, parent_id: str | None) -> list[dict]:
"""Get immediate child directories."""
return sorted(
[d for d in tree['dirs'].values() if d['parent_id'] == parent_id],
key=lambda d: d['name']
)
return sorted([d for d in tree['dirs'].values() if d['parent_id'] == parent_id], key=lambda d: d['name'])
def _get_files_under_dir(tree: dict, dir_id: str) -> list[dict]:
@@ -237,8 +245,9 @@ def _get_files_under_dir(tree: dict, dir_id: str) -> list[dict]:
# =============================================================================
async def _get_accessible_kb_ids(user: dict, model_knowledge: list[dict] | None,
knowledge_id: str | None = None) -> list[tuple[str, str, str]]:
async def _get_accessible_kb_ids(
user: dict, model_knowledge: list[dict] | None, knowledge_id: str | None = None
) -> list[tuple[str, str, str]]:
"""Get list of (kb_id, kb_name, kb_description) the user can access."""
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
@@ -249,11 +258,17 @@ async def _get_accessible_kb_ids(user: dict, model_knowledge: list[dict] | None,
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
async def _has_access(kb):
return (user_role == 'admin' or kb.user_id == user_id
or await AccessGrants.has_access(
user_id=user_id, resource_type='knowledge',
resource_id=kb.id, permission='read',
user_group_ids=set(user_group_ids)))
return (
user_role == 'admin'
or kb.user_id == user_id
or await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=kb.id,
permission='read',
user_group_ids=set(user_group_ids),
)
)
result = []
@@ -276,8 +291,10 @@ async def _get_accessible_kb_ids(user: dict, model_knowledge: list[dict] | None,
result.append((kb.id, kb.name, kb.description or ''))
else:
search = await Knowledges.search_knowledge_bases(
user_id, filter={'query': '', 'user_id': user_id, 'group_ids': user_group_ids},
skip=0, limit=50,
user_id,
filter={'query': '', 'user_id': user_id, 'group_ids': user_group_ids},
skip=0,
limit=50,
)
for kb in search.items:
result.append((kb.id, kb.name, kb.description or ''))
@@ -285,8 +302,9 @@ async def _get_accessible_kb_ids(user: dict, model_knowledge: list[dict] | None,
return result
async def _get_accessible_files(user: dict, model_knowledge: list[dict] | None,
knowledge_id: str | None = None) -> list[dict]:
async def _get_accessible_files(
user: dict, model_knowledge: list[dict] | None, knowledge_id: str | None = None
) -> list[dict]:
"""Get all files the user can access, with KB metadata and directory_id (no path computation)."""
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
@@ -297,15 +315,18 @@ async def _get_accessible_files(user: dict, model_knowledge: list[dict] | None,
for kb_id, kb_name, _ in kb_ids:
kb_files = await Knowledges.get_files_with_directory_ids(kb_id)
for file_model, dir_id in kb_files:
files.append({
'id': file_model.id, 'filename': file_model.filename,
'directory_id': dir_id,
'size': file_model.meta.get('size') if file_model.meta else None,
'type': file_model.meta.get('content_type') if file_model.meta else None,
'updated_at': file_model.updated_at,
'knowledge_id': kb_id,
'knowledge_name': kb_name,
})
files.append(
{
'id': file_model.id,
'filename': file_model.filename,
'directory_id': dir_id,
'size': file_model.meta.get('size') if file_model.meta else None,
'type': file_model.meta.get('content_type') if file_model.meta else None,
'updated_at': file_model.updated_at,
'knowledge_id': kb_id,
'knowledge_name': kb_name,
}
)
# Also handle directly attached files (not in any KB)
if model_knowledge:
@@ -316,14 +337,18 @@ async def _get_accessible_files(user: dict, model_knowledge: list[dict] | None,
for fid in attached_file_ids:
f = await Files.get_file_by_id(fid)
if f:
files.append({
'id': f.id, 'filename': f.filename,
'directory_id': None,
'size': f.meta.get('size') if f.meta else None,
'type': f.meta.get('content_type') if f.meta else None,
'updated_at': f.updated_at,
'knowledge_id': None, 'knowledge_name': None,
})
files.append(
{
'id': f.id,
'filename': f.filename,
'directory_id': None,
'size': f.meta.get('size') if f.meta else None,
'type': f.meta.get('content_type') if f.meta else None,
'updated_at': f.updated_at,
'knowledge_id': None,
'knowledge_name': None,
}
)
return files
@@ -374,8 +399,14 @@ async def _resolve_file(ref: str, user: dict, model_knowledge: list[dict] | None
if f and f.data:
if f.id not in accessible_ids:
return None
return {'id': f.id, 'filename': f.filename, 'content': f.data.get('content', ''),
'meta': f.meta, 'updated_at': f.updated_at, 'created_at': f.created_at}
return {
'id': f.id,
'filename': f.filename,
'content': f.data.get('content', ''),
'meta': f.meta,
'updated_at': f.updated_at,
'created_at': f.created_at,
}
# Try path match (e.g. "docs/api/auth.md") — lazy dir walk
ref_clean = ref.strip('/')
@@ -388,15 +419,20 @@ async def _resolve_file(ref: str, user: dict, model_knowledge: list[dict] | None
if dir_id is None:
continue
# Find file with that name in that directory
matches = [fi for fi in accessible
if fi['filename'] == filename and fi['directory_id'] == dir_id]
matches = [fi for fi in accessible if fi['filename'] == filename and fi['directory_id'] == dir_id]
if len(matches) == 1:
f = await Files.get_file_by_id(matches[0]['id'])
if f and f.data:
return {'id': f.id, 'filename': f.filename, 'content': f.data.get('content', ''),
'meta': f.meta, 'updated_at': f.updated_at, 'created_at': f.created_at,
'knowledge_id': matches[0].get('knowledge_id'),
'knowledge_name': matches[0].get('knowledge_name')}
return {
'id': f.id,
'filename': f.filename,
'content': f.data.get('content', ''),
'meta': f.meta,
'updated_at': f.updated_at,
'created_at': f.created_at,
'knowledge_id': matches[0].get('knowledge_id'),
'knowledge_name': matches[0].get('knowledge_name'),
}
# Try filename match within accessible files
matches = [fi for fi in accessible if fi['filename'] == ref]
@@ -404,13 +440,21 @@ async def _resolve_file(ref: str, user: dict, model_knowledge: list[dict] | None
if len(matches) == 1:
f = await Files.get_file_by_id(matches[0]['id'])
if f and f.data:
return {'id': f.id, 'filename': f.filename, 'content': f.data.get('content', ''),
'meta': f.meta, 'updated_at': f.updated_at, 'created_at': f.created_at,
'knowledge_id': matches[0].get('knowledge_id'),
'knowledge_name': matches[0].get('knowledge_name')}
return {
'id': f.id,
'filename': f.filename,
'content': f.data.get('content', ''),
'meta': f.meta,
'updated_at': f.updated_at,
'created_at': f.created_at,
'knowledge_id': matches[0].get('knowledge_id'),
'knowledge_name': matches[0].get('knowledge_name'),
}
elif len(matches) > 1:
return {'error': f'Ambiguous filename "{ref}". Use full path to disambiguate:\n' +
'\n'.join(f" {m['id']} {m['filename']} ({m.get('knowledge_name', 'direct')})" for m in matches)}
return {
'error': f'Ambiguous filename "{ref}". Use full path to disambiguate:\n'
+ '\n'.join(f' {m["id"]} {m["filename"]} ({m.get("knowledge_name", "direct")})' for m in matches)
}
return None
@@ -418,6 +462,7 @@ async def _resolve_file(ref: str, user: dict, model_knowledge: list[dict] | None
async def _get_file_content(file_id: str) -> str | None:
"""Get file content by ID."""
from open_webui.models.files import Files
f = await Files.get_file_by_id(file_id)
if f and f.data:
return f.data.get('content', '')
@@ -429,8 +474,7 @@ async def _get_file_content(file_id: str) -> str | None:
# =============================================================================
async def _kb_ls(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None) -> str:
async def _kb_ls(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str:
"""List files and directories. Supports: ls, ls <path>, ls -a (flat)."""
from open_webui.models.knowledge import Knowledges
@@ -506,13 +550,13 @@ def _fmt_size(f: dict) -> str:
def _fmt_date(f: dict) -> str:
if f.get('updated_at'):
from datetime import datetime, timezone
dt = datetime.fromtimestamp(f['updated_at'], tz=timezone.utc)
return dt.strftime('%Y-%m-%d')
return ''
async def _kb_cat(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None) -> str:
async def _kb_cat(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str:
"""Read file content. Use -n for line numbers."""
if not args:
return 'Usage: cat [-n] <file_id or filename>'
@@ -542,9 +586,9 @@ async def _kb_cat(args: list[str], flags: set[str], user: dict,
return content
async def _kb_head(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None,
piped_input: str | None = None) -> str:
async def _kb_head(
args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None
) -> str:
"""First N lines of a file or piped input."""
n, args = _extract_numeric_flag(args)
if n is None:
@@ -571,9 +615,9 @@ async def _kb_head(args: list[str], flags: set[str], user: dict,
return result
async def _kb_tail(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None,
piped_input: str | None = None) -> str:
async def _kb_tail(
args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None
) -> str:
"""Last N lines of a file or piped input."""
n, args = _extract_numeric_flag(args)
if n is None:
@@ -600,9 +644,9 @@ async def _kb_tail(args: list[str], flags: set[str], user: dict,
return result
async def _kb_grep(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None,
piped_input: str | None = None) -> str:
async def _kb_grep(
args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None
) -> str:
"""Text search across files or piped input. Supports -E for regex."""
if not args:
return 'Usage: grep [-E] [-i] [-l] [-c] "pattern" [file] [*.ext]'
@@ -684,8 +728,7 @@ async def _kb_grep(args: list[str], flags: set[str], user: dict,
accessible = [f for f in accessible if f['filename'].endswith(f'.{ext_filter}')]
if len(accessible) > MAX_GREP_FILES:
return (f'Too many files ({len(accessible)}). '
f'Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py')
return f'Too many files ({len(accessible)}). Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py'
from open_webui.models.files import Files
@@ -717,9 +760,7 @@ async def _kb_grep(args: list[str], flags: set[str], user: dict,
if not count_only and not filenames_only:
for line_num, line_text in file_matches:
if len(results) < MAX_GREP_MATCHES:
results.append(
f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}'
)
results.append(f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}')
if count_only:
if not file_match_counts:
@@ -742,8 +783,7 @@ async def _kb_grep(args: list[str], flags: set[str], user: dict,
return output
async def _kb_find(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None) -> str:
async def _kb_find(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str:
"""Find files by name/glob pattern, optionally scoped to a directory."""
if not args:
return 'Usage: find "*.md" or find docs/ "*.md"'
@@ -783,9 +823,9 @@ async def _kb_find(args: list[str], flags: set[str], user: dict,
return '\n'.join(lines)
async def _kb_wc(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None,
piped_input: str | None = None) -> str:
async def _kb_wc(
args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None
) -> str:
"""Word, line, character counts."""
if piped_input is not None:
lines = piped_input.count('\n') + (1 if piped_input and not piped_input.endswith('\n') else 0)
@@ -814,8 +854,7 @@ async def _kb_wc(args: list[str], flags: set[str], user: dict,
return f' {lines} {words} {chars} {resolved["filename"]}'
async def _kb_stat(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None) -> str:
async def _kb_stat(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str:
"""File metadata."""
if not args:
return 'Usage: stat <file>'
@@ -847,10 +886,12 @@ async def _kb_stat(args: list[str], flags: set[str], user: dict,
if resolved.get('created_at'):
from datetime import datetime, timezone
dt = datetime.fromtimestamp(resolved['created_at'], tz=timezone.utc)
out.append(f' Created: {dt.strftime("%Y-%m-%d %H:%M:%S UTC")}')
if resolved.get('updated_at'):
from datetime import datetime, timezone
dt = datetime.fromtimestamp(resolved['updated_at'], tz=timezone.utc)
out.append(f' Updated: {dt.strftime("%Y-%m-%d %H:%M:%S UTC")}')
if resolved.get('knowledge_name'):
@@ -859,20 +900,20 @@ async def _kb_stat(args: list[str], flags: set[str], user: dict,
return '\n'.join(out)
async def _kb_sed(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None,
piped_input: str | None = None) -> str:
async def _kb_sed(
args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None
) -> str:
"""Extract line range from a file. Usage: sed -n 'M,Np' <file>"""
if piped_input is not None:
# sed on piped input: parse range from args
start, end = 1, None
if 'n' in flags and args:
m = re.match(r"^(\d+),(\d+)p?$", args[0])
m = re.match(r'^(\d+),(\d+)p?$', args[0])
if m:
start, end = int(m.group(1)), int(m.group(2))
args = args[1:]
lines = piped_input.split('\n')
selected = lines[max(0, start - 1):(end or len(lines))]
selected = lines[max(0, start - 1) : (end or len(lines))]
return '\n'.join(selected)
# Parse: sed -n '40,60p' <file>
@@ -903,7 +944,7 @@ async def _kb_sed(args: list[str], flags: set[str], user: dict,
lines = resolved['content'].split('\n')
total = len(lines)
selected = lines[max(0, start - 1):end]
selected = lines[max(0, start - 1) : end]
result = '\n'.join(selected)
result += f'\n[lines {start}-{min(end, total)} of {total}]'
return result
@@ -914,8 +955,7 @@ async def _kb_sed(args: list[str], flags: set[str], user: dict,
# =============================================================================
async def _kb_tree(args: list[str], flags: set[str], user: dict,
model_knowledge: list[dict] | None) -> str:
async def _kb_tree(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str:
"""Show directory tree structure."""
kb_ids = await _get_accessible_kb_ids(user, model_knowledge)
if not kb_ids:
+1 -1
View File
@@ -426,7 +426,7 @@ async def get_current_user_by_api_key(request, api_key: str):
allowed_paths = [
path.strip() for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip()
]
request_path = request.scope["path"] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
if not is_allowed:
raise HTTPException(
+1 -5
View File
@@ -191,11 +191,7 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]:
# Owner, admin, and explicit read-grant holders are allowed.
if user is None:
return None
if (
file.user_id != user.id
and user.role != 'admin'
and not await has_access_to_file(file.id, 'read', user)
):
if file.user_id != user.id and user.role != 'admin' and not await has_access_to_file(file.id, 'read', user):
return None
try:
-1
View File
@@ -41,4 +41,3 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -
parsed_headers[key] = value
return parsed_headers
+1 -3
View File
@@ -67,9 +67,7 @@ def _json_sink(message: 'Message') -> None:
log_entry['error'] = {
'type': exc.type.__name__ if exc.type else None,
'message': str(exc.value) if exc.value else None,
'stacktrace': ''.join(
traceback.format_exception(exc.type, exc.value, exc.traceback)
).rstrip(),
'stacktrace': ''.join(traceback.format_exception(exc.type, exc.value, exc.traceback)).rstrip(),
}
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + '\n')
+5 -1
View File
@@ -11,7 +11,11 @@ from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, MCP_INITIALIZE_TIMEOUT
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
MCP_INITIALIZE_TIMEOUT,
)
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
+23 -19
View File
@@ -2257,7 +2257,6 @@ def strip_skill_mentions(messages: list[dict]) -> None:
part['text'] = strip_re.sub(r'\1', text).strip()
async def connect_mcp_server(
request,
server_id: str,
@@ -2271,10 +2270,7 @@ async def connect_mcp_server(
"""
mcp_server_connection = None
for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get('type', '') == 'mcp'
and server_connection.get('info', {}).get('id') == server_id
):
if server_connection.get('type', '') == 'mcp' and server_connection.get('info', {}).get('id') == server_id:
mcp_server_connection = server_connection
break
@@ -2287,8 +2283,12 @@ async def connect_mcp_server(
return None
headers, _ = await build_tool_server_headers(
mcp_server_connection, request, user,
server_id=server_id, metadata=metadata, extra_params=extra_params,
mcp_server_connection,
request,
user,
server_id=server_id,
metadata=metadata,
extra_params=extra_params,
)
client = MCPClient()
@@ -2297,18 +2297,13 @@ async def connect_mcp_server(
headers=headers if headers else None,
)
function_name_filter_list = mcp_server_connection.get('config', {}).get(
'function_name_filter_list', ''
)
function_name_filter_list = mcp_server_connection.get('config', {}).get('function_name_filter_list', '')
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(',')
tool_specs = await client.list_tool_specs()
if function_name_filter_list:
tool_specs = [
spec for spec in tool_specs
if is_string_allowed(spec['name'], function_name_filter_list)
]
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
return client, tool_specs
@@ -2730,10 +2725,14 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for tool_id in tool_ids:
if tool_id.startswith('server:mcp:'):
try:
server_id = tool_id[len('server:mcp:'):]
server_id = tool_id[len('server:mcp:') :]
result = await connect_mcp_server(
request, server_id, user, metadata, extra_params,
request,
server_id,
user,
metadata,
extra_params,
)
if result is None:
continue
@@ -2742,6 +2741,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
mcp_clients[server_id] = client
for tool_spec in tool_specs:
async def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await client.call_tool(
@@ -3392,7 +3392,9 @@ async def outlet_filter_handler(ctx):
if outlet_message_id and outlet_message_id in messages_map:
original_message = messages_map[outlet_message_id]
content_changed = original_message.get('content') != message.get('content')
output_changed = message.get('output') and message.get('output') != original_message.get('output')
output_changed = message.get('output') and message.get('output') != original_message.get(
'output'
)
if content_changed or output_changed:
# If output was modified, re-derive content from it
new_content = message.get('content', original_message.get('content', ''))
@@ -3874,7 +3876,7 @@ async def streaming_chat_response_handler(response, ctx):
# Mirror the five gates from utils/tools.py get_builtin_tools so the
# legacy XML-tag path enforces the same authz as native FC.
features = metadata.get('features', {}) or {}
model_capabilities = (model.get('info', {}).get('meta', {}).get('capabilities') or {})
model_capabilities = model.get('info', {}).get('meta', {}).get('capabilities') or {}
builtin_tools_meta = model.get('info', {}).get('meta', {}).get('builtinTools', {})
DETECT_CODE_INTERPRETER = (
bool(features.get('code_interpreter'))
@@ -4401,7 +4403,9 @@ async def streaming_chat_response_handler(response, ctx):
if end:
break
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith('channel:'):
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith(
'channel:'
):
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
+3 -9
View File
@@ -141,9 +141,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
Well-formed output is unaffected: every id pairs, so nothing is stripped.
"""
completed_tool_call_ids = {
message['tool_call_id']
for message in messages
if message.get('role') == 'tool' and message.get('tool_call_id')
message['tool_call_id'] for message in messages if message.get('role') == 'tool' and message.get('tool_call_id')
}
requested_tool_call_ids = {
tool_call['id']
@@ -167,9 +165,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
# Keep only tool_calls whose id received a tool-role response.
valid_tool_calls = [
tool_call
for tool_call in message['tool_calls']
if tool_call.get('id') in completed_tool_call_ids
tool_call for tool_call in message['tool_calls'] if tool_call.get('id') in completed_tool_call_ids
]
if valid_tool_calls:
@@ -181,9 +177,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
content = message.get('content', '')
has_meaningful_content = content.strip() if isinstance(content, str) else content
if has_meaningful_content or message.get('reasoning_content'):
reconciled_messages.append(
{key: value for key, value in message.items() if key != 'tool_calls'}
)
reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'})
return reconciled_messages
+3 -1
View File
@@ -375,7 +375,9 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM
except Exception as e:
log.debug(f'MCP Protected Resource discovery failed: {e}')
return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers, scopes_supported=scopes)
return ProtectedResourceMetadata(
resource=resource, authorization_servers=authorization_servers, scopes_supported=scopes
)
def _build_well_known_urls(server_url: str) -> list[str]:
+32 -37
View File
@@ -29,12 +29,12 @@ from open_webui.env import (
log = logging.getLogger(__name__)
_ACCEPTED_SCHEMES = frozenset({"redis", "rediss"})
_ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'})
_SENTINEL_RETRYABLE = (
_redis_sync.exceptions.ConnectionError,
_redis_sync.exceptions.ReadOnlyError,
)
_FACTORY_METHODS = frozenset({"pipeline", "pubsub", "monitor", "client", "transaction"})
_FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'})
_CONNECTION_POOL: dict[tuple, Any] = {}
@@ -42,17 +42,16 @@ def parse_redis_url(url: str) -> dict[str, Any]:
"""Break a ``redis://`` URL into its parts: service, port, db, username, password."""
parts: ParseResult = urlparse(url)
if parts.scheme not in _ACCEPTED_SCHEMES:
raise ValueError(
f"Invalid Redis URL scheme '{parts.scheme}'; expected 'redis' or 'rediss'."
)
raise ValueError(f"Invalid Redis URL scheme '{parts.scheme}'; expected 'redis' or 'rediss'.")
return {
"service": parts.hostname or "mymaster",
"port": parts.port or 6379,
"db": int(parts.path.lstrip("/") or "0"),
"username": parts.username or None,
"password": parts.password or None,
'service': parts.hostname or 'mymaster',
'port': parts.port or 6379,
'db': int(parts.path.lstrip('/') or '0'),
'username': parts.username or None,
'password': parts.password or None,
}
parse_redis_service_url = parse_redis_url
@@ -64,11 +63,7 @@ def get_sentinels_from_env(
if not hosts_csv:
return []
resolved_port = int(port) if port else 26379
return [
(host.strip(), resolved_port)
for host in hosts_csv.split(",")
if host.strip()
]
return [(host.strip(), resolved_port) for host in hosts_csv.split(',') if host.strip()]
def build_sentinel_url(
@@ -82,13 +77,11 @@ def build_sentinel_url(
``hosts_csv`` is a comma-separated list of sentinel hostnames.
"""
cfg = parse_redis_url(base_url)
auth = ""
if cfg["username"] or cfg["password"]:
auth = f"{cfg['username'] or ''}:{cfg['password'] or ''}@"
nodes = ",".join(
f"{host.strip()}:{port}" for host in hosts_csv.split(",") if host.strip()
)
return f"redis+sentinel://{auth}{nodes}/{cfg['db']}/{cfg['service']}"
auth = ''
if cfg['username'] or cfg['password']:
auth = f'{cfg["username"] or ""}:{cfg["password"] or ""}@'
nodes = ','.join(f'{host.strip()}:{port}' for host in hosts_csv.split(',') if host.strip())
return f'redis+sentinel://{auth}{nodes}/{cfg["db"]}/{cfg["service"]}'
def get_redis_client(async_mode: bool = False) -> Any | None:
@@ -107,7 +100,7 @@ def get_redis_client(async_mode: bool = False) -> Any | None:
async_mode=async_mode,
)
except Exception:
log.debug("Could not establish Redis connection", exc_info=True)
log.debug('Could not establish Redis connection', exc_info=True)
return None
@@ -157,7 +150,7 @@ class SentinelRedisProxy:
def _log_retry(self, exc: Exception, attempt: int) -> None:
log.debug(
"Sentinel failover (%s) — retry %d/%d",
'Sentinel failover (%s) — retry %d/%d',
type(exc).__name__,
attempt + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
@@ -165,7 +158,7 @@ class SentinelRedisProxy:
def _log_exhausted(self, exc: Exception) -> None:
log.error(
"Redis operation failed after %d retries: %s",
'Redis operation failed after %d retries: %s',
REDIS_SENTINEL_MAX_RETRY_COUNT,
exc,
)
@@ -254,11 +247,11 @@ def _socket_options() -> dict[str, Any]:
"""Collect optional socket-level kwargs once instead of repeating them."""
opts: dict[str, Any] = {}
if REDIS_SOCKET_CONNECT_TIMEOUT is not None:
opts["socket_connect_timeout"] = REDIS_SOCKET_CONNECT_TIMEOUT
opts['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT
if REDIS_SOCKET_KEEPALIVE:
opts["socket_keepalive"] = True
opts['socket_keepalive'] = True
if REDIS_HEALTH_CHECK_INTERVAL:
opts["health_check_interval"] = REDIS_HEALTH_CHECK_INTERVAL
opts['health_check_interval'] = REDIS_HEALTH_CHECK_INTERVAL
return opts
@@ -273,15 +266,15 @@ def _build_sentinel(
cfg = parse_redis_url(url)
sentinel = redis_module.sentinel.Sentinel(
sentinels,
port=cfg["port"],
db=cfg["db"],
username=cfg["username"],
password=cfg["password"],
port=cfg['port'],
db=cfg['db'],
username=cfg['username'],
password=cfg['password'],
decode_responses=decode_responses,
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
**{k: v for k, v in _socket_options().items() if k != "socket_connect_timeout"},
**{k: v for k, v in _socket_options().items() if k != 'socket_connect_timeout'},
)
return SentinelRedisProxy(sentinel, cfg["service"], async_mode=async_mode)
return SentinelRedisProxy(sentinel, cfg['service'], async_mode=async_mode)
def get_redis_connection(
@@ -320,12 +313,14 @@ def get_redis_connection(
connection = _build_sentinel(redis_mod, redis_url, redis_sentinels, decode_responses, async_mode)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL is required for cluster mode.")
raise ValueError('Redis URL is required for cluster mode.')
connection = redis_mod.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses, **extra,
redis_url,
decode_responses=decode_responses,
**extra,
)
elif redis_url:
factory = getattr(redis_mod, "from_url", None) or redis_mod.Redis.from_url
factory = getattr(redis_mod, 'from_url', None) or redis_mod.Redis.from_url
connection = factory(redis_url, decode_responses=decode_responses, **extra)
_CONNECTION_POOL[cache_key] = connection
+20 -8
View File
@@ -376,8 +376,12 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
metadata = extra_params.get('__metadata__', {})
headers, cookies = await build_tool_server_headers(
tool_server_connection, request, user,
server_id=server_id, metadata=metadata, extra_params=extra_params,
tool_server_connection,
request,
user,
server_id=server_id,
metadata=metadata,
extra_params=extra_params,
)
headers.setdefault('Content-Type', 'application/json')
@@ -484,7 +488,9 @@ async def get_builtin_tools(
builtin_functions.append(query_knowledge_bases)
builtin_functions.append(search_knowledge_bases)
elif model_knowledge:
builtin_functions.extend([list_knowledge, search_knowledge_files, grep_knowledge_files, query_knowledge_files])
builtin_functions.extend(
[list_knowledge, search_knowledge_files, grep_knowledge_files, query_knowledge_files]
)
knowledge_types = {item.get('type') for item in model_knowledge}
if 'file' in knowledge_types or 'collection' in knowledge_types:
@@ -492,11 +498,17 @@ async def get_builtin_tools(
if 'note' in knowledge_types:
builtin_functions.append(view_note)
else:
builtin_functions.extend([
list_knowledge_bases, search_knowledge_bases, query_knowledge_bases,
grep_knowledge_files, search_knowledge_files, query_knowledge_files,
view_knowledge_file,
])
builtin_functions.extend(
[
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
grep_knowledge_files,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
]
)
# Chats tools - search and fetch user's chat history
if is_builtin_tool_enabled('chats'):
+1 -2
View File
@@ -76,8 +76,7 @@ def validate_profile_image_url(url: str) -> str:
if _SAFE_DATA_URI_RE.match(url):
if PROFILE_IMAGE_MAX_DATA_URI_SIZE and len(url) > PROFILE_IMAGE_MAX_DATA_URI_SIZE:
raise ValueError(
f'Invalid profile image URL: data URI exceeds the '
f'{PROFILE_IMAGE_MAX_DATA_URI_SIZE}-byte limit.'
f'Invalid profile image URL: data URI exceeds the {PROFILE_IMAGE_MAX_DATA_URI_SIZE}-byte limit.'
)
return url