chore: format
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
+4
-1
@@ -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'])
|
||||
|
||||
+13
-3
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {},
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,4 +41,3 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -
|
||||
parsed_headers[key] = value
|
||||
|
||||
return parsed_headers
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user