Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ffc04797f | |||
| b2809e6293 | |||
| beb9bf60e4 | |||
| 2f9b28a57d | |||
| d501e3d6b5 | |||
| 819ad1d904 | |||
| 9fe3a00dba | |||
| 4584adf900 | |||
| dfdb76cc46 | |||
| 17df026492 | |||
| e038bab66d | |||
| c39be0e2d6 | |||
| 6cd0ba0b6b | |||
| e473ab1231 | |||
| 4dbb2f94a6 | |||
| dee07d8a30 | |||
| e031fadc35 | |||
| b4aef82401 | |||
| 5cdcdbaeec | |||
| e8d55c0a8b | |||
| 57a5e43696 | |||
| 7b29834d42 | |||
| 2b9b956dad | |||
| 25090dbf17 | |||
| bb2663aad1 | |||
| 3571db34e2 | |||
| 7d1f941580 | |||
| ed4cb358a0 | |||
| caedcbae49 | |||
| 9ccda6715c | |||
| edf3ae9209 | |||
| 0726db7217 | |||
| 9c6c375dfe | |||
| 3e3c5b6d78 | |||
| 2bc91e8f52 | |||
| e5ed45fb20 | |||
| 232421f40b | |||
| 6b2d962cd6 | |||
| 7ee75a0c04 | |||
| fc9c2ea191 | |||
| 6f2e97aa58 | |||
| 5f3a628a8d | |||
| 8b35ce924b | |||
| 966b3fdb57 | |||
| ee3a49a88d | |||
| 22f2fe1ffb | |||
| 993e749121 | |||
| 4c06b392da | |||
| fbcdcf146b | |||
| ec86ce5cf7 | |||
| 087878ce84 | |||
| 4f69c33de0 | |||
| c205d3a353 | |||
| 4210cae68e | |||
| de8ea08f5c | |||
| d07e4154fe | |||
| bb1419328b | |||
| 40c09167cd | |||
| 56ae99e96a | |||
| 1eecbc1ac2 | |||
| 78a5015846 | |||
| b93d560788 | |||
| b7626f05fb | |||
| 1fc0e3ade7 | |||
| 84e4110538 | |||
| 19a176fd36 | |||
| bb66d435b7 | |||
| dc4924b66e | |||
| 920b655f46 | |||
| 05098d25a5 | |||
| 3266a8c9eb | |||
| ecdb6f353a | |||
| 084d040e22 | |||
| 76854d1424 | |||
| 45fcf272ef | |||
| c783fd30f2 | |||
| d65ac445a4 | |||
| 38920c0ed1 | |||
| 5019af79a0 | |||
| f85cb27ef8 | |||
| 4602abe5c6 | |||
| b1d40f3409 |
@@ -310,3 +310,4 @@ dist
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
.vscode/settings.json
|
||||
.cptr
|
||||
|
||||
@@ -27,58 +27,84 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
||||
|
||||
## Key Features of Open WebUI ⭐
|
||||
|
||||
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
|
||||
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
|
||||
|
||||
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
|
||||
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
|
||||
|
||||
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
|
||||
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
|
||||
|
||||
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
|
||||
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
|
||||
|
||||
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
|
||||
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
|
||||
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
|
||||
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
|
||||
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
|
||||
|
||||
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
|
||||
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
|
||||
|
||||
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
|
||||
- ✅ **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
|
||||
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
|
||||
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
|
||||
|
||||
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
|
||||
|
||||
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
|
||||
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
|
||||
|
||||
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
|
||||
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
|
||||
|
||||
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
|
||||
|
||||
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
||||
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
|
||||
|
||||
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
|
||||
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
|
||||
|
||||
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
|
||||
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
|
||||
|
||||
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
|
||||
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
|
||||
|
||||
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
|
||||
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
|
||||
|
||||
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
|
||||
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
|
||||
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
|
||||
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
|
||||
|
||||
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
|
||||
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
|
||||
|
||||
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
|
||||
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
|
||||
|
||||
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
|
||||
|
||||
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
|
||||
|
||||
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
|
||||
|
||||
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
|
||||
|
||||
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
|
||||
|
||||
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
|
||||
|
||||
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
|
||||
|
||||
## The Open WebUI Ecosystem 🌐
|
||||
|
||||
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
|
||||
|
||||
- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
|
||||
|
||||
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
|
||||
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
|
||||
|
||||
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
|
||||
|
||||
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
|
||||
|
||||
---
|
||||
|
||||
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
|
||||
@@ -222,6 +248,10 @@ This project contains code under multiple licenses. The current codebase include
|
||||
If you have any questions, suggestions, or need assistance, please open an issue or join our
|
||||
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
|
||||
|
||||
## Security 🛡️
|
||||
|
||||
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#open-webui/open-webui&Date">
|
||||
|
||||
@@ -11,6 +11,7 @@ import uvicorn
|
||||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||
DEFAULT_SECRET_KEY_LENGTH = 24
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -37,8 +38,11 @@ def serve(
|
||||
if os.getenv('WEBUI_SECRET_KEY') is None:
|
||||
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
|
||||
if not KEY_FILE.exists():
|
||||
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
|
||||
if key_length < 1:
|
||||
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
|
||||
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
|
||||
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
|
||||
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
|
||||
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
|
||||
|
||||
|
||||
+822
-1905
File diff suppressed because it is too large
Load Diff
@@ -768,9 +768,11 @@ if LICENSE_PUBLIC_KEY:
|
||||
# WEBUI Identity
|
||||
####################################
|
||||
|
||||
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Aria')
|
||||
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI')
|
||||
if WEBUI_NAME != 'Open WebUI':
|
||||
WEBUI_NAME += ' (Open WebUI)'
|
||||
|
||||
WEBUI_FAVICON_URL = os.getenv('WEBUI_FAVICON_URL', '/static/favicon.png')
|
||||
WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png'
|
||||
WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build')
|
||||
TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
|
||||
|
||||
@@ -1064,4 +1066,4 @@ OTEL_METRICS_OTLP_SPAN_EXPORTER = os.getenv(
|
||||
|
||||
OTEL_LOGS_OTLP_SPAN_EXPORTER = os.getenv(
|
||||
'OTEL_LOGS_OTLP_SPAN_EXPORTER', OTEL_OTLP_SPAN_EXPORTER
|
||||
).lower() # grpc or http
|
||||
).lower() # grpc or http
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Database-backed configuration with environment variable defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import redis
|
||||
from open_webui.internal.db import Base, get_async_db, get_db
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, func, select
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigTable(Base):
|
||||
__tablename__ = 'config'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
data = Column(JSON, nullable=False)
|
||||
version = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True, onupdate=func.now())
|
||||
|
||||
|
||||
# ── Blob ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConfigState:
|
||||
"""In-memory mirror of the single-row config JSON blob."""
|
||||
|
||||
__slots__ = ('_data',)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def snapshot(self) -> dict:
|
||||
return self._data
|
||||
|
||||
def read(self, path: str) -> Any:
|
||||
return reduce(
|
||||
lambda n, k: n.get(k) if isinstance(n, dict) else None,
|
||||
path.split('.'),
|
||||
self._data,
|
||||
)
|
||||
|
||||
def write(self, path: str, value: Any) -> None:
|
||||
keys = path.split('.')
|
||||
reduce(lambda d, k: d.setdefault(k, {}), keys[:-1], self._data)[keys[-1]] = value
|
||||
|
||||
def replace(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def load(self) -> dict:
|
||||
with get_db() as db:
|
||||
row = db.query(ConfigTable).order_by(ConfigTable.id.desc()).first()
|
||||
self._data = row.data if row else {'version': 0, 'ui': {}}
|
||||
return self._data
|
||||
|
||||
def persist(self, data: dict | None = None) -> None:
|
||||
if data is not None:
|
||||
self._data = data
|
||||
with get_db() as db:
|
||||
row = db.query(ConfigTable).first()
|
||||
if row is None:
|
||||
db.add(ConfigTable(data=self._data, version=0))
|
||||
else:
|
||||
row.data, row.updated_at = self._data, datetime.now()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
async def persist_async(self, data: dict | None = None) -> None:
|
||||
if data is not None:
|
||||
self._data = data
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(ConfigTable).limit(1))
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
db.add(ConfigTable(data=self._data, version=0))
|
||||
else:
|
||||
row.data, row.updated_at = self._data, datetime.now()
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
|
||||
def clear(self) -> None:
|
||||
with get_db() as db:
|
||||
db.query(ConfigTable).delete()
|
||||
db.commit()
|
||||
|
||||
async def clear_async(self) -> None:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
async with get_async_db() as db:
|
||||
await db.execute(sa_delete(ConfigTable))
|
||||
await db.commit()
|
||||
|
||||
|
||||
STATE = ConfigState()
|
||||
|
||||
|
||||
# ── ConfigVar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_persist_enabled: bool = True
|
||||
_oauth_persist_enabled: bool = False
|
||||
_all_configs: list[ConfigVar] = []
|
||||
|
||||
|
||||
def initialize(*, enable_persistent: bool = True, enable_oauth_persistent: bool = False) -> dict:
|
||||
global _persist_enabled, _oauth_persist_enabled
|
||||
_persist_enabled = enable_persistent
|
||||
_oauth_persist_enabled = enable_oauth_persistent
|
||||
return STATE.load()
|
||||
|
||||
|
||||
class ConfigVar:
|
||||
__slots__ = ('env_name', 'config_path', 'env_value', 'config_value', 'value')
|
||||
|
||||
def __init__(self, env_name: str, config_path: str, env_value: Any) -> None:
|
||||
self.env_name = env_name
|
||||
self.config_path = config_path
|
||||
self.env_value = env_value
|
||||
self.config_value = STATE.read(config_path)
|
||||
|
||||
if self.config_value is not None and _persist_enabled:
|
||||
if config_path.startswith('oauth.') and not _oauth_persist_enabled:
|
||||
log.info("Skipping DB value for '%s' (OAuth persistence disabled)", env_name)
|
||||
self.value = env_value
|
||||
else:
|
||||
log.info("'%s' loaded from database", env_name)
|
||||
self.value = self.config_value
|
||||
else:
|
||||
self.value = env_value
|
||||
|
||||
_all_configs.append(self)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<ConfigVar {self.env_name}={self.value!r}>'
|
||||
|
||||
@property
|
||||
def __dict__(self): # type: ignore[override]
|
||||
raise TypeError(f"ConfigVar('{self.env_name}') cannot be cast to dict; use .value")
|
||||
|
||||
def __getattribute__(self, item: str):
|
||||
if item == '__dict__':
|
||||
raise TypeError('ConfigVar cannot be cast to dict; use .value')
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def refresh(self) -> None:
|
||||
current = STATE.read(self.config_path)
|
||||
if current is not None:
|
||||
self.value = current
|
||||
log.info('Refreshed %s → %s', self.env_name, self.value)
|
||||
|
||||
def commit(self) -> None:
|
||||
log.info("Persisting '%s'", self.env_name)
|
||||
STATE.write(self.config_path, self.value)
|
||||
self.config_value = self.value
|
||||
STATE.persist()
|
||||
|
||||
async def commit_async(self) -> None:
|
||||
log.info("Persisting '%s'", self.env_name)
|
||||
STATE.write(self.config_path, self.value)
|
||||
self.config_value = self.value
|
||||
await STATE.persist_async()
|
||||
|
||||
|
||||
# ── AppConfig ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""Attribute-style container for ConfigVars with optional Redis sync."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis_url: Optional[str] = None,
|
||||
redis_sentinels: Optional[list] = None,
|
||||
redis_cluster: bool = False,
|
||||
redis_key_prefix: str = 'open-webui',
|
||||
) -> None:
|
||||
super().__setattr__('_entries', {})
|
||||
super().__setattr__('_key_prefix', redis_key_prefix)
|
||||
|
||||
# If sentinels weren't explicitly provided, read from env.
|
||||
if redis_sentinels is None:
|
||||
from open_webui.env import REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
|
||||
from open_webui.utils.redis import get_sentinels_from_env
|
||||
|
||||
redis_sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT)
|
||||
|
||||
rc: Union[redis.Redis, redis.cluster.RedisCluster, None] = None
|
||||
if redis_url:
|
||||
rc = get_redis_connection(redis_url, redis_sentinels or [], redis_cluster, decode_responses=True)
|
||||
super().__setattr__('_rc', rc)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
entries: dict = super().__getattribute__('_entries')
|
||||
|
||||
if isinstance(value, ConfigVar):
|
||||
entries[name] = value
|
||||
return
|
||||
|
||||
entries[name].value = value
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(self._write_async(name))
|
||||
except RuntimeError:
|
||||
entries[name].commit()
|
||||
|
||||
rc = super().__getattribute__('_rc')
|
||||
if rc and _persist_enabled:
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
try:
|
||||
rc.set(f'{prefix}:config:{name}', json.dumps(entries[name].value))
|
||||
except Exception as exc:
|
||||
log.error("Redis write failed for '%s': %s", name, exc)
|
||||
|
||||
async def _write_async(self, name: str) -> None:
|
||||
try:
|
||||
await self._entries[name].commit_async()
|
||||
except Exception as exc:
|
||||
log.error("Async persist failed for '%s': %s", name, exc)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
entries = super().__getattribute__('_entries')
|
||||
if name not in entries:
|
||||
raise AttributeError(f"No config key '{name}'")
|
||||
|
||||
rc = super().__getattribute__('_rc')
|
||||
if rc and _persist_enabled:
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
try:
|
||||
raw = rc.get(f'{prefix}:config:{name}')
|
||||
if raw is not None:
|
||||
decoded = json.loads(raw)
|
||||
if entries[name].value != decoded:
|
||||
entries[name].value = decoded
|
||||
log.info("Updated '%s' from Redis", name)
|
||||
except Exception as exc:
|
||||
log.error("Redis read failed for '%s': %s", name, exc)
|
||||
|
||||
return entries[name].value
|
||||
|
||||
def _sync_to_redis(self) -> None:
|
||||
rc = super().__getattribute__('_rc')
|
||||
if not rc or not _persist_enabled:
|
||||
return
|
||||
prefix = super().__getattribute__('_key_prefix')
|
||||
for name, s in super().__getattribute__('_entries').items():
|
||||
try:
|
||||
rc.set(f'{prefix}:config:{name}', json.dumps(s.value))
|
||||
except Exception as exc:
|
||||
log.error("Redis sync failed for '%s': %s", name, exc)
|
||||
+305
-934
File diff suppressed because it is too large
Load Diff
+580
@@ -0,0 +1,580 @@
|
||||
"""reshape config to per key rows
|
||||
|
||||
Revision ID: 3ff2c63645b8
|
||||
Revises: 461111b60977
|
||||
Create Date: 2026-06-17 00:50:51.477073
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3ff2c63645b8'
|
||||
down_revision: Union[str, None] = '461111b60977'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Maps every dot-notation blob path to its legacy env/config key name.
|
||||
# Built from the legacy persistent config declarations in config.py.
|
||||
BLOB_PATH_TO_KEY = {
|
||||
"audio.stt.allowed_extensions": "AUDIO_STT_ALLOWED_EXTENSIONS",
|
||||
"audio.stt.azure.api_key": "AUDIO_STT_AZURE_API_KEY",
|
||||
"audio.stt.azure.base_url": "AUDIO_STT_AZURE_BASE_URL",
|
||||
"audio.stt.azure.locales": "AUDIO_STT_AZURE_LOCALES",
|
||||
"audio.stt.azure.max_speakers": "AUDIO_STT_AZURE_MAX_SPEAKERS",
|
||||
"audio.stt.azure.region": "AUDIO_STT_AZURE_REGION",
|
||||
"audio.stt.deepgram.api_key": "DEEPGRAM_API_KEY",
|
||||
"audio.stt.engine": "AUDIO_STT_ENGINE",
|
||||
"audio.stt.mistral.api_base_url": "AUDIO_STT_MISTRAL_API_BASE_URL",
|
||||
"audio.stt.mistral.api_key": "AUDIO_STT_MISTRAL_API_KEY",
|
||||
"audio.stt.mistral.use_chat_completions": "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS",
|
||||
"audio.stt.model": "AUDIO_STT_MODEL",
|
||||
"audio.stt.openai.api_base_url": "AUDIO_STT_OPENAI_API_BASE_URL",
|
||||
"audio.stt.openai.api_key": "AUDIO_STT_OPENAI_API_KEY",
|
||||
"audio.stt.supported_content_types": "AUDIO_STT_SUPPORTED_CONTENT_TYPES",
|
||||
"audio.stt.whisper_model": "WHISPER_MODEL",
|
||||
"audio.tts.api_key": "AUDIO_TTS_API_KEY",
|
||||
"audio.tts.azure.speech_base_url": "AUDIO_TTS_AZURE_SPEECH_BASE_URL",
|
||||
"audio.tts.azure.speech_output_format": "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT",
|
||||
"audio.tts.azure.speech_region": "AUDIO_TTS_AZURE_SPEECH_REGION",
|
||||
"audio.tts.engine": "AUDIO_TTS_ENGINE",
|
||||
"audio.tts.mistral.api_base_url": "AUDIO_TTS_MISTRAL_API_BASE_URL",
|
||||
"audio.tts.mistral.api_key": "AUDIO_TTS_MISTRAL_API_KEY",
|
||||
"audio.tts.model": "AUDIO_TTS_MODEL",
|
||||
"audio.tts.openai.api_base_url": "AUDIO_TTS_OPENAI_API_BASE_URL",
|
||||
"audio.tts.openai.api_key": "AUDIO_TTS_OPENAI_API_KEY",
|
||||
"audio.tts.openai.params": "AUDIO_TTS_OPENAI_PARAMS",
|
||||
"audio.tts.split_on": "AUDIO_TTS_SPLIT_ON",
|
||||
"audio.tts.voice": "AUDIO_TTS_VOICE",
|
||||
"auth.admin.email": "ADMIN_EMAIL",
|
||||
"auth.admin.show": "SHOW_ADMIN_DETAILS",
|
||||
"auth.api_key.allowed_endpoints": "API_KEYS_ALLOWED_ENDPOINTS",
|
||||
"auth.api_key.endpoint_restrictions": "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
|
||||
"auth.enable_api_keys": "ENABLE_API_KEYS",
|
||||
"auth.jwt_expiry": "JWT_EXPIRES_IN",
|
||||
"automations.enable": "ENABLE_AUTOMATIONS",
|
||||
"automations.max_count": "AUTOMATION_MAX_COUNT",
|
||||
"automations.min_interval": "AUTOMATION_MIN_INTERVAL",
|
||||
"calendar.enable": "ENABLE_CALENDAR",
|
||||
"channels.enable": "ENABLE_CHANNELS",
|
||||
"code_execution.enable": "ENABLE_CODE_EXECUTION",
|
||||
"code_execution.engine": "CODE_EXECUTION_ENGINE",
|
||||
"code_execution.jupyter.auth": "CODE_EXECUTION_JUPYTER_AUTH",
|
||||
"code_execution.jupyter.auth_password": "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD",
|
||||
"code_execution.jupyter.auth_token": "CODE_EXECUTION_JUPYTER_AUTH_TOKEN",
|
||||
"code_execution.jupyter.timeout": "CODE_EXECUTION_JUPYTER_TIMEOUT",
|
||||
"code_execution.jupyter.url": "CODE_EXECUTION_JUPYTER_URL",
|
||||
"code_interpreter.enable": "ENABLE_CODE_INTERPRETER",
|
||||
"code_interpreter.engine": "CODE_INTERPRETER_ENGINE",
|
||||
"code_interpreter.jupyter.auth": "CODE_INTERPRETER_JUPYTER_AUTH",
|
||||
"code_interpreter.jupyter.auth_password": "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
|
||||
"code_interpreter.jupyter.auth_token": "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
|
||||
"code_interpreter.jupyter.timeout": "CODE_INTERPRETER_JUPYTER_TIMEOUT",
|
||||
"code_interpreter.jupyter.url": "CODE_INTERPRETER_JUPYTER_URL",
|
||||
"code_interpreter.prompt_template": "CODE_INTERPRETER_PROMPT_TEMPLATE",
|
||||
"direct.enable": "ENABLE_DIRECT_CONNECTIONS",
|
||||
"evaluation.arena.enable": "ENABLE_EVALUATION_ARENA_MODELS",
|
||||
"evaluation.arena.models": "EVALUATION_ARENA_MODELS",
|
||||
"file.image_compression_height": "FILE_IMAGE_COMPRESSION_HEIGHT",
|
||||
"file.image_compression_width": "FILE_IMAGE_COMPRESSION_WIDTH",
|
||||
"folders.enable": "ENABLE_FOLDERS",
|
||||
"folders.max_file_count": "FOLDER_MAX_FILE_COUNT",
|
||||
"google_drive.api_key": "GOOGLE_DRIVE_API_KEY",
|
||||
"google_drive.client_id": "GOOGLE_DRIVE_CLIENT_ID",
|
||||
"google_drive.enable": "ENABLE_GOOGLE_DRIVE_INTEGRATION",
|
||||
"image_generation.automatic1111.api_auth": "AUTOMATIC1111_API_AUTH",
|
||||
"image_generation.automatic1111.api_params": "AUTOMATIC1111_PARAMS",
|
||||
"image_generation.automatic1111.base_url": "AUTOMATIC1111_BASE_URL",
|
||||
"image_generation.comfyui.api_key": "COMFYUI_API_KEY",
|
||||
"image_generation.comfyui.base_url": "COMFYUI_BASE_URL",
|
||||
"image_generation.comfyui.nodes": "COMFYUI_WORKFLOW_NODES",
|
||||
"image_generation.comfyui.workflow": "COMFYUI_WORKFLOW",
|
||||
"image_generation.enable": "ENABLE_IMAGE_GENERATION",
|
||||
"image_generation.engine": "IMAGE_GENERATION_ENGINE",
|
||||
"image_generation.gemini.api_base_url": "IMAGES_GEMINI_API_BASE_URL",
|
||||
"image_generation.gemini.api_key": "IMAGES_GEMINI_API_KEY",
|
||||
"image_generation.gemini.endpoint_method": "IMAGES_GEMINI_ENDPOINT_METHOD",
|
||||
"image_generation.model": "IMAGE_GENERATION_MODEL",
|
||||
"image_generation.openai.api_base_url": "IMAGES_OPENAI_API_BASE_URL",
|
||||
"image_generation.openai.api_key": "IMAGES_OPENAI_API_KEY",
|
||||
"image_generation.openai.api_version": "IMAGES_OPENAI_API_VERSION",
|
||||
"image_generation.openai.params": "IMAGES_OPENAI_API_PARAMS",
|
||||
"image_generation.prompt.enable": "ENABLE_IMAGE_PROMPT_GENERATION",
|
||||
"image_generation.size": "IMAGE_SIZE",
|
||||
"image_generation.steps": "IMAGE_STEPS",
|
||||
"images.edit.comfyui.api_key": "IMAGES_EDIT_COMFYUI_API_KEY",
|
||||
"images.edit.comfyui.base_url": "IMAGES_EDIT_COMFYUI_BASE_URL",
|
||||
"images.edit.comfyui.nodes": "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
|
||||
"images.edit.comfyui.workflow": "IMAGES_EDIT_COMFYUI_WORKFLOW",
|
||||
"images.edit.enable": "ENABLE_IMAGE_EDIT",
|
||||
"images.edit.engine": "IMAGE_EDIT_ENGINE",
|
||||
"images.edit.gemini.api_base_url": "IMAGES_EDIT_GEMINI_API_BASE_URL",
|
||||
"images.edit.gemini.api_key": "IMAGES_EDIT_GEMINI_API_KEY",
|
||||
"images.edit.model": "IMAGE_EDIT_MODEL",
|
||||
"images.edit.openai.api_base_url": "IMAGES_EDIT_OPENAI_API_BASE_URL",
|
||||
"images.edit.openai.api_key": "IMAGES_EDIT_OPENAI_API_KEY",
|
||||
"images.edit.openai.api_version": "IMAGES_EDIT_OPENAI_API_VERSION",
|
||||
"images.edit.size": "IMAGE_EDIT_SIZE",
|
||||
"ldap.enable": "ENABLE_LDAP",
|
||||
"ldap.group.enable_creation": "ENABLE_LDAP_GROUP_CREATION",
|
||||
"ldap.group.enable_management": "ENABLE_LDAP_GROUP_MANAGEMENT",
|
||||
"ldap.server.app_dn": "LDAP_APP_DN",
|
||||
"ldap.server.app_password": "LDAP_APP_PASSWORD",
|
||||
"ldap.server.attribute_for_groups": "LDAP_ATTRIBUTE_FOR_GROUPS",
|
||||
"ldap.server.attribute_for_mail": "LDAP_ATTRIBUTE_FOR_MAIL",
|
||||
"ldap.server.attribute_for_username": "LDAP_ATTRIBUTE_FOR_USERNAME",
|
||||
"ldap.server.ca_cert_file": "LDAP_CA_CERT_FILE",
|
||||
"ldap.server.ciphers": "LDAP_CIPHERS",
|
||||
"ldap.server.host": "LDAP_SERVER_HOST",
|
||||
"ldap.server.label": "LDAP_SERVER_LABEL",
|
||||
"ldap.server.port": "LDAP_SERVER_PORT",
|
||||
"ldap.server.search_filter": "LDAP_SEARCH_FILTER",
|
||||
"ldap.server.use_tls": "LDAP_USE_TLS",
|
||||
"ldap.server.users_dn": "LDAP_SEARCH_BASE",
|
||||
"ldap.server.validate_cert": "LDAP_VALIDATE_CERT",
|
||||
"memories.enable": "ENABLE_MEMORIES",
|
||||
"models.base_models_cache": "ENABLE_BASE_MODELS_CACHE",
|
||||
"models.default_metadata": "DEFAULT_MODEL_METADATA",
|
||||
"models.default_params": "DEFAULT_MODEL_PARAMS",
|
||||
"notes.enable": "ENABLE_NOTES",
|
||||
# OAuth — direct paths
|
||||
"oauth.admin_roles": "OAUTH_ADMIN_ROLES",
|
||||
"oauth.allowed_domains": "OAUTH_ALLOWED_DOMAINS",
|
||||
"oauth.allowed_roles": "OAUTH_ALLOWED_ROLES",
|
||||
"oauth.audience": "OAUTH_AUDIENCE",
|
||||
"oauth.auto_redirect": "OAUTH_AUTO_REDIRECT",
|
||||
"oauth.blocked_groups": "OAUTH_BLOCKED_GROUPS",
|
||||
"oauth.client.timeout": "OAUTH_CLIENT_TIMEOUT",
|
||||
"oauth.enable_group_creation": "ENABLE_OAUTH_GROUP_CREATION",
|
||||
"oauth.enable_group_mapping": "ENABLE_OAUTH_GROUP_MANAGEMENT",
|
||||
"oauth.enable_role_mapping": "ENABLE_OAUTH_ROLE_MANAGEMENT",
|
||||
"oauth.enable_signup": "ENABLE_OAUTH_SIGNUP",
|
||||
"oauth.group_default_share": "OAUTH_GROUP_DEFAULT_SHARE",
|
||||
"oauth.merge_accounts_by_email": "OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
|
||||
"oauth.refresh_token_include_scope": "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE",
|
||||
"oauth.roles_claim": "OAUTH_ROLES_CLAIM",
|
||||
"oauth.update_email_on_login": "OAUTH_UPDATE_EMAIL_ON_LOGIN",
|
||||
"oauth.update_name_on_login": "OAUTH_UPDATE_NAME_ON_LOGIN",
|
||||
"oauth.update_picture_on_login": "OAUTH_UPDATE_PICTURE_ON_LOGIN",
|
||||
# OAuth — generic provider paths
|
||||
"oauth.client_id": "OAUTH_CLIENT_ID",
|
||||
"oauth.client_secret": "OAUTH_CLIENT_SECRET",
|
||||
"oauth.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
|
||||
"oauth.email_claim": "OAUTH_EMAIL_CLAIM",
|
||||
"oauth.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
|
||||
"oauth.group_claim": "OAUTH_GROUP_CLAIM",
|
||||
"oauth.picture_claim": "OAUTH_PICTURE_CLAIM",
|
||||
"oauth.provider_name": "OAUTH_PROVIDER_NAME",
|
||||
"oauth.provider_url": "OPENID_PROVIDER_URL",
|
||||
"oauth.redirect_uri": "OPENID_REDIRECT_URI",
|
||||
"oauth.scopes": "OAUTH_SCOPES",
|
||||
"oauth.sub_claim": "OAUTH_SUB_CLAIM",
|
||||
"oauth.timeout": "OAUTH_TIMEOUT",
|
||||
"oauth.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
|
||||
"oauth.username_claim": "OAUTH_USERNAME_CLAIM",
|
||||
# OAuth — OIDC nested paths (flattened)
|
||||
"oauth.oidc.avatar_claim": "OAUTH_PICTURE_CLAIM",
|
||||
"oauth.oidc.client_id": "OAUTH_CLIENT_ID",
|
||||
"oauth.oidc.client_secret": "OAUTH_CLIENT_SECRET",
|
||||
"oauth.oidc.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
|
||||
"oauth.oidc.email_claim": "OAUTH_EMAIL_CLAIM",
|
||||
"oauth.oidc.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
|
||||
"oauth.oidc.group_claim": "OAUTH_GROUP_CLAIM", # renamed from OAUTH_GROUPS_CLAIM
|
||||
"oauth.oidc.oauth_timeout": "OAUTH_TIMEOUT",
|
||||
"oauth.oidc.provider_name": "OAUTH_PROVIDER_NAME",
|
||||
"oauth.oidc.provider_url": "OPENID_PROVIDER_URL",
|
||||
"oauth.oidc.redirect_uri": "OPENID_REDIRECT_URI",
|
||||
"oauth.oidc.scopes": "OAUTH_SCOPES",
|
||||
"oauth.oidc.sub_claim": "OAUTH_SUB_CLAIM",
|
||||
"oauth.oidc.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
|
||||
"oauth.oidc.username_claim": "OAUTH_USERNAME_CLAIM",
|
||||
# OAuth — provider-specific
|
||||
"oauth.feishu.client_id": "FEISHU_CLIENT_ID",
|
||||
"oauth.feishu.client_secret": "FEISHU_CLIENT_SECRET",
|
||||
"oauth.feishu.redirect_uri": "FEISHU_REDIRECT_URI",
|
||||
"oauth.feishu.scope": "FEISHU_OAUTH_SCOPE",
|
||||
"oauth.github.client_id": "GITHUB_CLIENT_ID",
|
||||
"oauth.github.client_secret": "GITHUB_CLIENT_SECRET",
|
||||
"oauth.github.redirect_uri": "GITHUB_CLIENT_REDIRECT_URI",
|
||||
"oauth.github.scope": "GITHUB_CLIENT_SCOPE",
|
||||
"oauth.google.client_id": "GOOGLE_CLIENT_ID",
|
||||
"oauth.google.client_secret": "GOOGLE_CLIENT_SECRET",
|
||||
"oauth.google.redirect_uri": "GOOGLE_REDIRECT_URI",
|
||||
"oauth.google.scope": "GOOGLE_OAUTH_SCOPE",
|
||||
"oauth.microsoft.client_id": "MICROSOFT_CLIENT_ID",
|
||||
"oauth.microsoft.client_secret": "MICROSOFT_CLIENT_SECRET",
|
||||
"oauth.microsoft.login_base_url": "MICROSOFT_CLIENT_LOGIN_BASE_URL",
|
||||
"oauth.microsoft.picture_url": "MICROSOFT_CLIENT_PICTURE_URL",
|
||||
"oauth.microsoft.redirect_uri": "MICROSOFT_REDIRECT_URI",
|
||||
"oauth.microsoft.scope": "MICROSOFT_OAUTH_SCOPE",
|
||||
"oauth.microsoft.tenant_id": "MICROSOFT_CLIENT_TENANT_ID",
|
||||
# Ollama / OpenAI
|
||||
"ollama.api_configs": "OLLAMA_API_CONFIGS",
|
||||
"ollama.base_urls": "OLLAMA_BASE_URLS",
|
||||
"ollama.enable": "ENABLE_OLLAMA_API",
|
||||
"onedrive.enable": "ENABLE_ONEDRIVE_INTEGRATION",
|
||||
"onedrive.sharepoint_tenant_id": "ONEDRIVE_SHAREPOINT_TENANT_ID",
|
||||
"onedrive.sharepoint_url": "ONEDRIVE_SHAREPOINT_URL",
|
||||
"openai.api_base_urls": "OPENAI_API_BASE_URLS",
|
||||
"openai.api_configs": "OPENAI_API_CONFIGS",
|
||||
"openai.api_keys": "OPENAI_API_KEYS",
|
||||
"openai.enable": "ENABLE_OPENAI_API",
|
||||
# RAG
|
||||
"rag.content_extraction_engine": "CONTENT_EXTRACTION_ENGINE",
|
||||
"rag.datalab_marker_use_llm": "DATALAB_MARKER_USE_LLM",
|
||||
"rag.mistral_ocr_api_base_url": "MISTRAL_OCR_API_BASE_URL",
|
||||
"rag.azure_openai.api_key": "RAG_AZURE_OPENAI_API_KEY",
|
||||
"rag.azure_openai.api_version": "RAG_AZURE_OPENAI_API_VERSION",
|
||||
"rag.azure_openai.base_url": "RAG_AZURE_OPENAI_BASE_URL",
|
||||
"rag.bypass_embedding_and_retrieval": "BYPASS_EMBEDDING_AND_RETRIEVAL",
|
||||
"rag.chunk_min_size_target": "CHUNK_MIN_SIZE_TARGET",
|
||||
"rag.chunk_overlap": "CHUNK_OVERLAP",
|
||||
"rag.chunk_size": "CHUNK_SIZE",
|
||||
"rag.datalab_marker_additional_config": "DATALAB_MARKER_ADDITIONAL_CONFIG",
|
||||
"rag.datalab_marker_api_base_url": "DATALAB_MARKER_API_BASE_URL",
|
||||
"rag.datalab_marker_api_key": "DATALAB_MARKER_API_KEY",
|
||||
"rag.datalab_marker_disable_image_extraction": "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION",
|
||||
"rag.datalab_marker_force_ocr": "DATALAB_MARKER_FORCE_OCR",
|
||||
"rag.datalab_marker_format_lines": "DATALAB_MARKER_FORMAT_LINES",
|
||||
"rag.datalab_marker_output_format": "DATALAB_MARKER_OUTPUT_FORMAT",
|
||||
"rag.datalab_marker_paginate": "DATALAB_MARKER_PAGINATE",
|
||||
"rag.datalab_marker_skip_cache": "DATALAB_MARKER_SKIP_CACHE",
|
||||
"rag.datalab_marker_strip_existing_ocr": "DATALAB_MARKER_STRIP_EXISTING_OCR",
|
||||
"rag.docling_api_key": "DOCLING_API_KEY",
|
||||
"rag.docling_params": "DOCLING_PARAMS",
|
||||
"rag.docling_server_url": "DOCLING_SERVER_URL",
|
||||
"rag.document_intelligence_endpoint": "DOCUMENT_INTELLIGENCE_ENDPOINT",
|
||||
"rag.document_intelligence_key": "DOCUMENT_INTELLIGENCE_KEY",
|
||||
"rag.document_intelligence_model": "DOCUMENT_INTELLIGENCE_MODEL",
|
||||
"rag.embedding_batch_size": "RAG_EMBEDDING_BATCH_SIZE",
|
||||
"rag.embedding_concurrent_requests": "RAG_EMBEDDING_CONCURRENT_REQUESTS",
|
||||
"rag.embedding_engine": "RAG_EMBEDDING_ENGINE",
|
||||
"rag.embedding_model": "RAG_EMBEDDING_MODEL",
|
||||
"rag.enable_async_embedding": "ENABLE_ASYNC_EMBEDDING",
|
||||
"rag.enable_hybrid_search": "ENABLE_RAG_HYBRID_SEARCH",
|
||||
"rag.enable_hybrid_search_enriched_texts": "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS",
|
||||
"rag.enable_markdown_header_text_splitter": "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
|
||||
"rag.external_document_loader_api_key": "EXTERNAL_DOCUMENT_LOADER_API_KEY",
|
||||
"rag.external_document_loader_url": "EXTERNAL_DOCUMENT_LOADER_URL",
|
||||
"rag.external_reranker_api_key": "RAG_EXTERNAL_RERANKER_API_KEY",
|
||||
"rag.external_reranker_timeout": "RAG_EXTERNAL_RERANKER_TIMEOUT",
|
||||
"rag.external_reranker_url": "RAG_EXTERNAL_RERANKER_URL",
|
||||
"rag.file.allowed_extensions": "RAG_ALLOWED_FILE_EXTENSIONS",
|
||||
"rag.file.max_count": "RAG_FILE_MAX_COUNT",
|
||||
"rag.file.max_size": "RAG_FILE_MAX_SIZE",
|
||||
"rag.full_context": "RAG_FULL_CONTEXT",
|
||||
"rag.hybrid_bm25_weight": "RAG_HYBRID_BM25_WEIGHT",
|
||||
"rag.mineru_api_key": "MINERU_API_KEY",
|
||||
"rag.mineru_api_mode": "MINERU_API_MODE",
|
||||
"rag.mineru_api_timeout": "MINERU_API_TIMEOUT",
|
||||
"rag.mineru_api_url": "MINERU_API_URL",
|
||||
"rag.mineru_file_extensions": "MINERU_FILE_EXTENSIONS",
|
||||
"rag.mineru_params": "MINERU_PARAMS",
|
||||
"rag.mistral_ocr_api_key": "MISTRAL_OCR_API_KEY",
|
||||
"rag.ollama.key": "RAG_OLLAMA_API_KEY",
|
||||
"rag.ollama.url": "RAG_OLLAMA_BASE_URL",
|
||||
"rag.openai_api_base_url": "RAG_OPENAI_API_BASE_URL",
|
||||
"rag.openai_api_key": "RAG_OPENAI_API_KEY",
|
||||
"rag.paddleocr_vl_base_url": "PADDLEOCR_VL_BASE_URL",
|
||||
"rag.paddleocr_vl_token": "PADDLEOCR_VL_TOKEN",
|
||||
"rag.pdf_extract_images": "PDF_EXTRACT_IMAGES",
|
||||
"rag.pdf_loader_mode": "PDF_LOADER_MODE",
|
||||
"rag.relevance_threshold": "RAG_RELEVANCE_THRESHOLD",
|
||||
"rag.reranking_batch_size": "RAG_RERANKING_BATCH_SIZE",
|
||||
"rag.reranking_engine": "RAG_RERANKING_ENGINE",
|
||||
"rag.reranking_model": "RAG_RERANKING_MODEL",
|
||||
"rag.template": "RAG_TEMPLATE",
|
||||
"rag.text_splitter": "RAG_TEXT_SPLITTER",
|
||||
"rag.tika_server_url": "TIKA_SERVER_URL",
|
||||
"rag.tiktoken_encoding_name": "TIKTOKEN_ENCODING_NAME",
|
||||
"rag.top_k": "RAG_TOP_K",
|
||||
"rag.top_k_reranker": "RAG_TOP_K_RERANKER",
|
||||
# RAG — Web
|
||||
"rag.web.fetch.max_content_length": "WEB_FETCH_MAX_CONTENT_LENGTH",
|
||||
"rag.web.loader.concurrent_requests": "WEB_LOADER_CONCURRENT_REQUESTS",
|
||||
"rag.web.loader.engine": "WEB_LOADER_ENGINE",
|
||||
"rag.web.loader.external_web_loader_api_key": "EXTERNAL_WEB_LOADER_API_KEY",
|
||||
"rag.web.loader.external_web_loader_url": "EXTERNAL_WEB_LOADER_URL",
|
||||
"rag.web.loader.firecrawl_api_key": "FIRECRAWL_API_KEY",
|
||||
"rag.web.loader.firecrawl_api_url": "FIRECRAWL_API_BASE_URL",
|
||||
"rag.web.loader.firecrawl_timeout": "FIRECRAWL_TIMEOUT",
|
||||
"rag.web.loader.playwright_timeout": "PLAYWRIGHT_TIMEOUT",
|
||||
"rag.web.loader.playwright_ws_url": "PLAYWRIGHT_WS_URL",
|
||||
"rag.web.loader.ssl_verification": "ENABLE_WEB_LOADER_SSL_VERIFICATION",
|
||||
"rag.web.loader.timeout": "WEB_LOADER_TIMEOUT",
|
||||
"rag.web.search.azure_ai_search_api_key": "AZURE_AI_SEARCH_API_KEY",
|
||||
"rag.web.search.azure_ai_search_endpoint": "AZURE_AI_SEARCH_ENDPOINT",
|
||||
"rag.web.search.azure_ai_search_index_name": "AZURE_AI_SEARCH_INDEX_NAME",
|
||||
"rag.web.search.bing_search_v7_endpoint": "BING_SEARCH_V7_ENDPOINT",
|
||||
"rag.web.search.bing_search_v7_subscription_key": "BING_SEARCH_V7_SUBSCRIPTION_KEY",
|
||||
"rag.web.search.bocha_search_api_key": "BOCHA_SEARCH_API_KEY",
|
||||
"rag.web.search.brave_search_api_key": "BRAVE_SEARCH_API_KEY",
|
||||
"rag.web.search.brave_search_context_tokens": "BRAVE_SEARCH_CONTEXT_TOKENS",
|
||||
"rag.web.search.bypass_embedding_and_retrieval": "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL",
|
||||
"rag.web.search.bypass_web_loader": "BYPASS_WEB_SEARCH_WEB_LOADER",
|
||||
"rag.web.search.concurrent_requests": "WEB_SEARCH_CONCURRENT_REQUESTS",
|
||||
"rag.web.search.ddgs_backend": "DDGS_BACKEND",
|
||||
"rag.web.search.domain.filter_list": "WEB_SEARCH_DOMAIN_FILTER_LIST",
|
||||
"rag.web.search.enable": "ENABLE_WEB_SEARCH",
|
||||
"rag.web.search.engine": "WEB_SEARCH_ENGINE",
|
||||
"rag.web.search.exa_api_key": "EXA_API_KEY",
|
||||
"rag.web.search.external_web_search_api_key": "EXTERNAL_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.external_web_search_url": "EXTERNAL_WEB_SEARCH_URL",
|
||||
"rag.web.search.google_pse_api_key": "GOOGLE_PSE_API_KEY",
|
||||
"rag.web.search.google_pse_engine_id": "GOOGLE_PSE_ENGINE_ID",
|
||||
"rag.web.search.jina_api_base_url": "JINA_API_BASE_URL",
|
||||
"rag.web.search.jina_api_key": "JINA_API_KEY",
|
||||
"rag.web.search.kagi_search_api_key": "KAGI_SEARCH_API_KEY",
|
||||
"rag.web.search.linkup_api_key": "LINKUP_API_KEY",
|
||||
"rag.web.search.linkup_search_params": "LINKUP_SEARCH_PARAMS",
|
||||
"rag.web.search.mojeek_search_api_key": "MOJEEK_SEARCH_API_KEY",
|
||||
"rag.web.search.ollama_cloud_api_key": "OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.perplexity_api_key": "PERPLEXITY_API_KEY",
|
||||
"rag.web.search.perplexity_model": "PERPLEXITY_MODEL",
|
||||
"rag.web.search.perplexity_search_api_url": "PERPLEXITY_SEARCH_API_URL",
|
||||
"rag.web.search.perplexity_search_context_usage": "PERPLEXITY_SEARCH_CONTEXT_USAGE",
|
||||
"rag.web.search.result_count": "WEB_SEARCH_RESULT_COUNT",
|
||||
"rag.web.search.searchapi_api_key": "SEARCHAPI_API_KEY",
|
||||
"rag.web.search.searchapi_engine": "SEARCHAPI_ENGINE",
|
||||
"rag.web.search.searxng_language": "SEARXNG_LANGUAGE",
|
||||
"rag.web.search.searxng_query_url": "SEARXNG_QUERY_URL",
|
||||
"rag.web.search.serpapi_api_key": "SERPAPI_API_KEY",
|
||||
"rag.web.search.serpapi_engine": "SERPAPI_ENGINE",
|
||||
"rag.web.search.serper_api_key": "SERPER_API_KEY",
|
||||
"rag.web.search.serply_api_key": "SERPLY_API_KEY",
|
||||
"rag.web.search.serpstack_api_key": "SERPSTACK_API_KEY",
|
||||
"rag.web.search.serpstack_https": "SERPSTACK_HTTPS",
|
||||
"rag.web.search.sougou_api_sid": "SOUGOU_API_SID",
|
||||
"rag.web.search.sougou_api_sk": "SOUGOU_API_SK",
|
||||
"rag.web.search.tavily_api_key": "TAVILY_API_KEY",
|
||||
"rag.web.search.tavily_extract_depth": "TAVILY_EXTRACT_DEPTH",
|
||||
"rag.web.search.trust_env": "WEB_SEARCH_TRUST_ENV",
|
||||
"rag.web.search.yacy_password": "YACY_PASSWORD",
|
||||
"rag.web.search.yacy_query_url": "YACY_QUERY_URL",
|
||||
"rag.web.search.yacy_username": "YACY_USERNAME",
|
||||
"rag.web.search.yandex_web_search_api_key": "YANDEX_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.yandex_web_search_config": "YANDEX_WEB_SEARCH_CONFIG",
|
||||
"rag.web.search.yandex_web_search_url": "YANDEX_WEB_SEARCH_URL",
|
||||
"rag.web.search.youcom_api_key": "YOUCOM_API_KEY",
|
||||
"rag.youtube_loader_language": "YOUTUBE_LOADER_LANGUAGE",
|
||||
"rag.youtube_loader_proxy_url": "YOUTUBE_LOADER_PROXY_URL",
|
||||
# Tasks
|
||||
"task.autocomplete.enable": "ENABLE_AUTOCOMPLETE_GENERATION",
|
||||
"task.autocomplete.input_max_length": "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
|
||||
"task.autocomplete.prompt_template": "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.follow_up.enable": "ENABLE_FOLLOW_UP_GENERATION",
|
||||
"task.follow_up.prompt_template": "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.image.prompt_template": "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.model.default": "TASK_MODEL",
|
||||
"task.model.external": "TASK_MODEL_EXTERNAL",
|
||||
"task.query.prompt_template": "QUERY_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.query.retrieval.enable": "ENABLE_RETRIEVAL_QUERY_GENERATION",
|
||||
"task.query.search.enable": "ENABLE_SEARCH_QUERY_GENERATION",
|
||||
"task.tags.enable": "ENABLE_TAGS_GENERATION",
|
||||
"task.tags.prompt_template": "TAGS_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.title.enable": "ENABLE_TITLE_GENERATION",
|
||||
"task.title.prompt_template": "TITLE_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.tools.prompt_template": "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE",
|
||||
"task.voice.prompt.enable": "ENABLE_VOICE_MODE_PROMPT",
|
||||
"task.voice.prompt_template": "VOICE_MODE_PROMPT_TEMPLATE",
|
||||
# Misc
|
||||
"terminal_server.connections": "TERMINAL_SERVER_CONNECTIONS",
|
||||
"tool_server.connections": "TOOL_SERVER_CONNECTIONS",
|
||||
"ui.banners": "WEBUI_BANNERS",
|
||||
"ui.default_group_id": "DEFAULT_GROUP_ID",
|
||||
"ui.default_locale": "DEFAULT_LOCALE",
|
||||
"ui.default_models": "DEFAULT_MODELS",
|
||||
"ui.default_pinned_models": "DEFAULT_PINNED_MODELS",
|
||||
"ui.default_user_role": "DEFAULT_USER_ROLE",
|
||||
"ui.enable_community_sharing": "ENABLE_COMMUNITY_SHARING",
|
||||
"ui.enable_login_form": "ENABLE_LOGIN_FORM",
|
||||
"ui.enable_message_rating": "ENABLE_MESSAGE_RATING",
|
||||
"ui.enable_password_change_form": "ENABLE_PASSWORD_CHANGE_FORM",
|
||||
"ui.enable_signup": "ENABLE_SIGNUP",
|
||||
"ui.enable_user_webhooks": "ENABLE_USER_WEBHOOKS",
|
||||
"ui.model_order_list": "MODEL_ORDER_LIST",
|
||||
"ui.pending_user_overlay_content": "PENDING_USER_OVERLAY_CONTENT",
|
||||
"ui.pending_user_overlay_title": "PENDING_USER_OVERLAY_TITLE",
|
||||
"ui.prompt_suggestions": "DEFAULT_PROMPT_SUGGESTIONS",
|
||||
"ui.watermark": "RESPONSE_WATERMARK",
|
||||
"user.permissions": "USER_PERMISSIONS",
|
||||
"users.enable_status": "ENABLE_USER_STATUS",
|
||||
"webhook_url": "WEBHOOK_URL",
|
||||
"webui.url": "WEBUI_URL",
|
||||
}
|
||||
|
||||
|
||||
STORAGE_KEY_REWRITES = {
|
||||
"oauth.refresh_token_include_scope": "oauth.refresh_token.include_scope",
|
||||
|
||||
"rag.openai_api_base_url": "rag.openai.api_base_url",
|
||||
"rag.openai_api_key": "rag.openai.api_key",
|
||||
"rag.ollama.url": "rag.ollama.base_url",
|
||||
"rag.ollama.key": "rag.ollama.api_key",
|
||||
"oauth.oidc.avatar_claim": "oauth.picture_claim",
|
||||
"oauth.oidc.client_id": "oauth.client_id",
|
||||
"oauth.oidc.client_secret": "oauth.client_secret",
|
||||
"oauth.oidc.code_challenge_method": "oauth.code_challenge_method",
|
||||
"oauth.oidc.email_claim": "oauth.email_claim",
|
||||
"oauth.oidc.end_session_endpoint": "oauth.end_session_endpoint",
|
||||
"oauth.oidc.group_claim": "oauth.group_claim",
|
||||
"oauth.oidc.oauth_timeout": "oauth.timeout",
|
||||
"oauth.oidc.provider_name": "oauth.provider_name",
|
||||
"oauth.oidc.provider_url": "oauth.provider_url",
|
||||
"oauth.oidc.redirect_uri": "oauth.redirect_uri",
|
||||
"oauth.oidc.scopes": "oauth.scopes",
|
||||
"oauth.oidc.sub_claim": "oauth.sub_claim",
|
||||
"oauth.oidc.token_endpoint_auth_method": "oauth.token_endpoint_auth_method",
|
||||
"oauth.oidc.username_claim": "oauth.username_claim",
|
||||
}
|
||||
|
||||
|
||||
LEGACY_KEY_TO_STORAGE_KEY = {
|
||||
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path)
|
||||
for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
|
||||
}
|
||||
|
||||
|
||||
def _walk_blob(data: dict, prefix: str = '') -> dict:
|
||||
"""Recursively walk a nested dict, yielding (dot.path, value) for leaf nodes."""
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
|
||||
if isinstance(value, dict):
|
||||
result.update(_walk_blob(value, path))
|
||||
else:
|
||||
result[path] = value
|
||||
return result
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Reshape config from single-row JSON blob to per-key rows."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = set(inspector.get_table_names())
|
||||
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
|
||||
has_old_config = {'id', 'data'}.issubset(config_columns)
|
||||
has_new_config = {'key', 'value'}.issubset(config_columns)
|
||||
|
||||
# Ad-hoc table reference for reading the old schema
|
||||
old_config = sa.table(
|
||||
'config',
|
||||
sa.column('id', sa.Integer),
|
||||
sa.column('data', sa.JSON),
|
||||
)
|
||||
|
||||
# 1. Read existing blob
|
||||
blob_data = {}
|
||||
if has_old_config:
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1)
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row and row[0]:
|
||||
raw = row[0]
|
||||
blob_data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
pass # Table might be partially migrated or empty
|
||||
|
||||
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
|
||||
if has_old_config:
|
||||
if 'config_old' in table_names:
|
||||
op.drop_table('config_old')
|
||||
op.rename_table('config', 'config_old')
|
||||
|
||||
# 3. Create new per-key table
|
||||
new_config = (
|
||||
sa.table(
|
||||
'config',
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('value', sa.JSON()),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
if has_new_config
|
||||
else op.create_table(
|
||||
'config',
|
||||
sa.Column('key', sa.Text(), primary_key=True),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Flatten blob and insert per-key rows
|
||||
if blob_data:
|
||||
flat = _walk_blob(blob_data)
|
||||
|
||||
# Keep stable dot-notation paths as the database keys.
|
||||
# Known legacy env-style keys are rewritten to their dotted keys; unknown
|
||||
# keys are still copied so custom/future config is not silently lost.
|
||||
rows = {}
|
||||
for blob_path, value in flat.items():
|
||||
if blob_path in BLOB_PATH_TO_KEY:
|
||||
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
|
||||
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
|
||||
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
|
||||
else:
|
||||
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
|
||||
|
||||
if storage_key not in rows:
|
||||
rows[storage_key] = value
|
||||
|
||||
# Batch insert via SQLAlchemy table reference
|
||||
if rows:
|
||||
now = int(time.time())
|
||||
op.bulk_insert(
|
||||
new_config,
|
||||
[
|
||||
{'key': k, 'value': v, 'updated_at': now}
|
||||
for k, v in rows.items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore preserved old single-row config table when available."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if 'config_old' in table_names:
|
||||
if 'config' in table_names:
|
||||
op.drop_table('config')
|
||||
op.rename_table('config_old', 'config')
|
||||
return
|
||||
|
||||
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
|
||||
has_per_key_config = {'key', 'value'}.issubset(config_columns)
|
||||
|
||||
blob_data = {}
|
||||
if has_per_key_config:
|
||||
config = sa.table(
|
||||
'config',
|
||||
sa.column('key', sa.Text),
|
||||
sa.column('value', sa.JSON),
|
||||
)
|
||||
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
|
||||
blob_data[key] = json.loads(value) if isinstance(value, str) else value
|
||||
op.drop_table('config')
|
||||
|
||||
if 'config' in table_names and not has_per_key_config:
|
||||
return
|
||||
|
||||
old_config = op.create_table(
|
||||
'config',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
if blob_data:
|
||||
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
@@ -15,6 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Pre-computed hash verified on signin paths that lack a real credential
|
||||
# (unknown user, inactive account) so response timing cannot reveal
|
||||
# whether an account exists (CWE-208).
|
||||
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
class Auth(Base): # credential ↔ user linkage
|
||||
"""Maps a user ID to an email/password pair with an active flag."""
|
||||
@@ -142,11 +148,13 @@ class AuthsTable:
|
||||
log.info('authenticate_user: %s', email)
|
||||
resolved = await Users.get_user_by_email(email, db=db)
|
||||
if not resolved:
|
||||
verify_password(PLACEHOLDER_HASH)
|
||||
return
|
||||
# load the credential row and verify the password hash
|
||||
async with get_async_db_context(db) as session:
|
||||
credential = await session.get(Auth, resolved.id)
|
||||
if not credential or not credential.active:
|
||||
verify_password(PLACEHOLDER_HASH)
|
||||
return
|
||||
if not verify_password(credential.password):
|
||||
return
|
||||
|
||||
@@ -3,6 +3,8 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select, delete, func, cast, Integer, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.utils.response import normalize_usage
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -440,6 +442,44 @@ class ChatMessageTable:
|
||||
result = await db.execute(stmt)
|
||||
return {row.model_id: row.count for row in result.all()}
|
||||
|
||||
async def get_unique_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Count distinct users and chats per model."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.model_id,
|
||||
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
|
||||
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
|
||||
).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
result = await db.execute(stmt)
|
||||
return {
|
||||
row.model_id: {
|
||||
'unique_users': row.unique_users,
|
||||
'unique_chats': row.unique_chats,
|
||||
}
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
async def get_token_usage_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
|
||||
@@ -309,6 +309,7 @@ class ChatTable:
|
||||
'folder_id': form_data.folder_id,
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
'last_read_at': int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -445,7 +446,6 @@ class ChatTable:
|
||||
clean_title = self._clean_null_bytes(title)
|
||||
chat_item.title = clean_title
|
||||
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
|
||||
chat_item.updated_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
return ChatModel.model_validate(chat_item)
|
||||
@@ -748,6 +748,7 @@ class ChatTable:
|
||||
chat = await session.get(Chat, id)
|
||||
chat.pinned = not chat.pinned
|
||||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
@@ -761,6 +762,7 @@ class ChatTable:
|
||||
chat.archived = not chat.archived
|
||||
chat.folder_id = None
|
||||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
@@ -829,6 +831,17 @@ class ChatTable:
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def count_archived_chats_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -957,6 +970,20 @@ class ChatTable:
|
||||
all_chats = result.scalars().all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
async def get_chat_metas_by_chat_ids(
|
||||
self,
|
||||
chat_ids: list[str],
|
||||
include_archived: bool = False,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat.meta).filter(Chat.id.in_(chat_ids))
|
||||
if not include_archived:
|
||||
stmt = stmt.filter_by(archived=False)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [meta for meta in result.scalars().all() if isinstance(meta, dict)]
|
||||
|
||||
# retrieve conversation
|
||||
async def get_chat_by_id(
|
||||
self,
|
||||
@@ -1353,6 +1380,42 @@ class ChatTable:
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def get_all_chats_by_folder_id(
|
||||
self,
|
||||
folder_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 60,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = (
|
||||
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
)
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
all_chats = result.all()
|
||||
return [
|
||||
{
|
||||
'id': chat[0],
|
||||
'title': chat[1],
|
||||
'user_id': chat[2],
|
||||
'updated_at': chat[3],
|
||||
'created_at': chat[4],
|
||||
'last_read_at': chat[5],
|
||||
}
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def get_chats_by_folder_ids_and_user_id(
|
||||
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
@@ -1377,6 +1440,7 @@ class ChatTable:
|
||||
chat = await session.get(Chat, id)
|
||||
chat.folder_id = folder_id
|
||||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
chat.pinned = False
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
@@ -1521,6 +1585,21 @@ class ChatTable:
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
return count
|
||||
|
||||
async def count_chats_by_folder_ids_and_user_id(
|
||||
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
||||
) -> int:
|
||||
if not folder_ids:
|
||||
return 0
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
|
||||
)
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folders '{folder_ids}': {count}")
|
||||
return count
|
||||
|
||||
async def delete_tag_by_id_and_user_id_and_tag_name(
|
||||
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
|
||||
) -> bool:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Database-backed configuration with per-key storage.
|
||||
|
||||
Replaces the old single-row JSON blob machinery with a simple per-key model
|
||||
mirroring cptr's Config.
|
||||
|
||||
Each config key is stored as its own row: key TEXT PK, value JSON.
|
||||
Reads are direct DB lookups. Writes are explicit awaited upserts that raise on
|
||||
failure (no more fire-and-forget create_task).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Config(Base):
|
||||
"""Per-key config storage. Each row is one config key."""
|
||||
|
||||
__tablename__ = 'config'
|
||||
|
||||
key = Column(Text, primary_key=True)
|
||||
value = Column(JSON, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=True)
|
||||
|
||||
DEFAULTS: ClassVar[dict[str, Any]] = {}
|
||||
PERSISTENT_ENABLED: ClassVar[bool] = True
|
||||
OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False
|
||||
|
||||
# ── Class methods ────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
cls,
|
||||
*,
|
||||
defaults: dict[str, Any] | None = None,
|
||||
enable_persistent: bool = True,
|
||||
enable_oauth_persistent: bool = False,
|
||||
) -> None:
|
||||
cls.DEFAULTS = defaults or {}
|
||||
cls.PERSISTENT_ENABLED = enable_persistent
|
||||
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
|
||||
|
||||
@classmethod
|
||||
def default_value(cls, key: str, default: Any = None) -> Any:
|
||||
return cls.DEFAULTS.get(key, default)
|
||||
|
||||
@classmethod
|
||||
def persistent_enabled_for(cls, key: str) -> bool:
|
||||
if not cls.PERSISTENT_ENABLED:
|
||||
return False
|
||||
if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get(key: str, default: Any = None) -> Any:
|
||||
"""Get a config value by key. Returns default if not set."""
|
||||
if not Config.persistent_enabled_for(key):
|
||||
return Config.default_value(key, default)
|
||||
async with get_async_db() as db:
|
||||
row = await db.get(Config, key)
|
||||
return row.value if row else Config.default_value(key, default)
|
||||
|
||||
@staticmethod
|
||||
async def get_many(*keys: str) -> dict:
|
||||
"""Get multiple config values. Returns {key: value} for keys that exist."""
|
||||
disabled_values = {
|
||||
key: Config.default_value(key)
|
||||
for key in keys
|
||||
if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS
|
||||
}
|
||||
enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)}
|
||||
if not enabled_keys:
|
||||
return disabled_values
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config).where(Config.key.in_(enabled_keys)))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
return {
|
||||
key: values.get(key, Config.default_value(key))
|
||||
for key in keys
|
||||
if key in values or key in Config.DEFAULTS or key in disabled_values
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_namespace(namespace: str) -> dict:
|
||||
"""Get all config keys under a dotted namespace."""
|
||||
default_values = {
|
||||
key: value
|
||||
for key, value in Config.DEFAULTS.items()
|
||||
if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key)
|
||||
}
|
||||
if not Config.PERSISTENT_ENABLED:
|
||||
return default_values
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%')))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
values.update(default_values)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Get all config as {key: value}."""
|
||||
if not Config.PERSISTENT_ENABLED:
|
||||
return dict(Config.DEFAULTS)
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config))
|
||||
values = {row.key: row.value for row in result.scalars().all()}
|
||||
if not Config.OAUTH_PERSISTENT_ENABLED:
|
||||
values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')})
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
async def upsert(updates: dict) -> None:
|
||||
"""Upsert multiple config key-value pairs. Raises on failure."""
|
||||
async with get_async_db() as db:
|
||||
now = int(time.time())
|
||||
for key, value in updates.items():
|
||||
existing = await db.get(Config, key)
|
||||
if existing:
|
||||
existing.value = value
|
||||
existing.updated_at = now
|
||||
else:
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> bool:
|
||||
"""Delete a config key. Returns True if it existed."""
|
||||
async with get_async_db() as db:
|
||||
row = await db.get(Config, key)
|
||||
if row:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def clear() -> None:
|
||||
"""Delete all config rows."""
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
async with get_async_db() as db:
|
||||
await db.execute(sa_delete(Config))
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def seed_defaults(defaults: dict) -> None:
|
||||
"""Insert keys that don't yet exist in the DB.
|
||||
|
||||
Called at startup to ensure all known config keys have values.
|
||||
Existing DB values take precedence over defaults.
|
||||
"""
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config.key))
|
||||
existing_keys = {row[0] for row in result.all()}
|
||||
|
||||
now = int(time.time())
|
||||
new_count = 0
|
||||
for key, value in defaults.items():
|
||||
if key not in existing_keys:
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
existing_keys.add(key)
|
||||
new_count += 1
|
||||
|
||||
if new_count:
|
||||
await db.commit()
|
||||
log.info('Seeded %d new config defaults', new_count)
|
||||
@@ -133,6 +133,12 @@ class ModelHistoryEntry(BaseModel):
|
||||
lost: int
|
||||
|
||||
|
||||
class ModelHistoryCounts(BaseModel):
|
||||
date: str
|
||||
won: int = 0
|
||||
lost: int = 0
|
||||
|
||||
|
||||
class ModelHistoryResponse(BaseModel):
|
||||
model_id: str
|
||||
history: list[ModelHistoryEntry]
|
||||
@@ -216,12 +222,19 @@ class FeedbackTable:
|
||||
) -> FeedbackListResponse:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
|
||||
count_stmt = (
|
||||
select(func.count(Feedback.id))
|
||||
.select_from(Feedback)
|
||||
.join(User, Feedback.user_id == User.id)
|
||||
)
|
||||
|
||||
if filter:
|
||||
# Apply model_id filter (exact match)
|
||||
model_id = filter.get('model_id')
|
||||
if model_id:
|
||||
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
|
||||
model_id_filter = Feedback.data['model_id'].as_string() == model_id
|
||||
stmt = stmt.filter(model_id_filter)
|
||||
count_stmt = count_stmt.filter(model_id_filter)
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
@@ -250,9 +263,9 @@ class FeedbackTable:
|
||||
else:
|
||||
stmt = stmt.order_by(Feedback.created_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
# Count before pagination without wrapping the ordered item query.
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
@@ -375,6 +388,45 @@ class FeedbackTable:
|
||||
|
||||
return result
|
||||
|
||||
async def get_model_feedback_counts_by_day(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[ModelHistoryCounts]:
|
||||
"""Get aggregated feedback counts per day for a model, preserving all matching days."""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
|
||||
if start_date is not None:
|
||||
stmt = stmt.filter(Feedback.created_at >= start_date)
|
||||
|
||||
result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
|
||||
rows = result.all()
|
||||
|
||||
daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
|
||||
|
||||
for created_at, data in rows:
|
||||
if not data:
|
||||
continue
|
||||
|
||||
rating_str = str(data.get('rating', ''))
|
||||
if rating_str not in ('1', '-1'):
|
||||
continue
|
||||
|
||||
date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
|
||||
if rating_str == '1':
|
||||
daily_counts[date_str]['won'] += 1
|
||||
else:
|
||||
daily_counts[date_str]['lost'] += 1
|
||||
|
||||
return [
|
||||
ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
|
||||
for date_str, counts in sorted(daily_counts.items())
|
||||
]
|
||||
|
||||
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
|
||||
|
||||
@@ -201,6 +201,18 @@ class FilesTable:
|
||||
result = await db.execute(select(File))
|
||||
return [FileModel.model_validate(file) for file in result.scalars().all()]
|
||||
|
||||
async def count_files_by_user_id(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(func.count(File.id))
|
||||
if user_id:
|
||||
stmt = stmt.filter_by(user_id=user_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def check_access_by_user_id(self, id, user_id, permission='write', db: AsyncSession | None = None) -> bool:
|
||||
file = await self.get_file_by_id(id, db=db)
|
||||
if not file:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -62,6 +62,20 @@ class FolderNameIdResponse(BaseModel):
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SharedFolderResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
user_id: str
|
||||
owner_name: Optional[str] = None
|
||||
permission: str = 'read'
|
||||
access_grants: list = []
|
||||
is_expanded: bool = False
|
||||
meta: Optional[dict] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
@@ -130,6 +144,56 @@ class FolderTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_folder_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[FolderModel]:
|
||||
"""Fetch folder by ID only (no user_id filter). Used for shared access."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter_by(id=id))
|
||||
folder = result.scalars().first()
|
||||
if not folder:
|
||||
return None
|
||||
return FolderModel.model_validate(folder)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_shared_folder_ids_for_user(
|
||||
self, user_id: str, user_group_ids: set[str],
|
||||
db: Optional[AsyncSession] = None
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Returns {folder_id: highest_permission} for all folders shared with user.
|
||||
Checks direct user grants, group grants, and public (user:*) grants.
|
||||
"""
|
||||
from open_webui.models.access_grants import AccessGrant
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
conditions = [
|
||||
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'),
|
||||
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id),
|
||||
]
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(AccessGrant.principal_type == 'group',
|
||||
AccessGrant.principal_id.in_(user_group_ids))
|
||||
)
|
||||
result = await db.execute(
|
||||
select(AccessGrant).filter(
|
||||
AccessGrant.resource_type == 'folder',
|
||||
or_(*conditions),
|
||||
)
|
||||
)
|
||||
grants = result.scalars().all()
|
||||
|
||||
# Build {folder_id: highest_permission} ('write' > 'read')
|
||||
folder_perms = {}
|
||||
for g in grants:
|
||||
existing = folder_perms.get(g.resource_id)
|
||||
if existing != 'write':
|
||||
folder_perms[g.resource_id] = g.permission
|
||||
return folder_perms
|
||||
|
||||
async def get_children_folders_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[list[FolderModel]]:
|
||||
@@ -188,6 +252,25 @@ class FolderTable:
|
||||
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
|
||||
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
|
||||
|
||||
async def get_folder_ids_by_id_and_user_id_in_subtree(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[str]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
|
||||
folder = result.scalars().first()
|
||||
if not folder:
|
||||
return []
|
||||
|
||||
folder_ids = [folder.id]
|
||||
folders = [FolderModel.model_validate(folder)]
|
||||
while folders:
|
||||
current_folder = folders.pop()
|
||||
children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db)
|
||||
folder_ids.extend(child.id for child in children)
|
||||
folders.extend(children)
|
||||
|
||||
return folder_ids
|
||||
|
||||
async def update_folder_parent_id_by_id_and_user_id(
|
||||
self,
|
||||
id: str,
|
||||
|
||||
@@ -201,5 +201,15 @@ class SharedChatsTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def delete_all_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete all shared chats created by a user."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(delete(SharedChat).filter_by(user_id=user_id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
SharedChats = SharedChatsTable()
|
||||
|
||||
@@ -39,6 +39,7 @@ from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
@@ -63,65 +64,84 @@ def is_youtube_url(url: str) -> bool:
|
||||
return re.match(youtube_regex, url) is not None
|
||||
|
||||
|
||||
def get_loader(request, url: str):
|
||||
LOADER_CONFIG_KEYS = {
|
||||
'youtube_language': 'rag.youtube_loader_language',
|
||||
'youtube_proxy_url': 'rag.youtube_loader_proxy_url',
|
||||
'web_loader_ssl_verification': 'rag.web.loader.ssl_verification',
|
||||
'web_loader_concurrent_requests': 'rag.web.loader.concurrent_requests',
|
||||
'web_search_trust_env': 'rag.web.search.trust_env',
|
||||
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
|
||||
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
|
||||
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
|
||||
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
|
||||
'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache',
|
||||
'DATALAB_MARKER_FORCE_OCR': 'rag.datalab_marker_force_ocr',
|
||||
'DATALAB_MARKER_PAGINATE': 'rag.datalab_marker_paginate',
|
||||
'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr',
|
||||
'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': 'rag.datalab_marker_disable_image_extraction',
|
||||
'DATALAB_MARKER_FORMAT_LINES': 'rag.datalab_marker_format_lines',
|
||||
'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm',
|
||||
'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format',
|
||||
'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url',
|
||||
'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key',
|
||||
'TIKA_SERVER_URL': 'rag.tika_server_url',
|
||||
'DOCLING_SERVER_URL': 'rag.docling_server_url',
|
||||
'DOCLING_API_KEY': 'rag.docling_api_key',
|
||||
'DOCLING_PARAMS': 'rag.docling_params',
|
||||
'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images',
|
||||
'PDF_LOADER_MODE': 'rag.pdf_loader_mode',
|
||||
'DOCUMENT_INTELLIGENCE_ENDPOINT': 'rag.document_intelligence_endpoint',
|
||||
'DOCUMENT_INTELLIGENCE_KEY': 'rag.document_intelligence_key',
|
||||
'DOCUMENT_INTELLIGENCE_MODEL': 'rag.document_intelligence_model',
|
||||
'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url',
|
||||
'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key',
|
||||
'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url',
|
||||
'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token',
|
||||
'MINERU_API_MODE': 'rag.mineru_api_mode',
|
||||
'MINERU_API_URL': 'rag.mineru_api_url',
|
||||
'MINERU_API_KEY': 'rag.mineru_api_key',
|
||||
'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout',
|
||||
'MINERU_PARAMS': 'rag.mineru_params',
|
||||
'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions',
|
||||
}
|
||||
|
||||
|
||||
async def get_loader_config():
|
||||
values = await Config.get_many(*LOADER_CONFIG_KEYS.values())
|
||||
return {name: values.get(key) for name, key in LOADER_CONFIG_KEYS.items()}
|
||||
|
||||
|
||||
def get_loader(request, url: str, config: dict):
|
||||
if is_youtube_url(url):
|
||||
return YoutubeLoader(
|
||||
url,
|
||||
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
language=config.get('youtube_language'),
|
||||
proxy_url=config.get('youtube_proxy_url'),
|
||||
)
|
||||
else:
|
||||
return get_web_loader(
|
||||
url,
|
||||
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
|
||||
)
|
||||
|
||||
|
||||
def build_loader_from_config(request):
|
||||
"""Build a Loader instance with the admin's configured extraction engine settings."""
|
||||
from open_webui.retrieval.loaders.main import Loader
|
||||
|
||||
config = request.app.state.config
|
||||
return Loader(
|
||||
engine=config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=config.DOCLING_SERVER_URL,
|
||||
DOCLING_API_KEY=config.DOCLING_API_KEY,
|
||||
DOCLING_PARAMS=config.DOCLING_PARAMS,
|
||||
PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES,
|
||||
PDF_LOADER_MODE=config.PDF_LOADER_MODE,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
|
||||
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
|
||||
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
|
||||
PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL,
|
||||
PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN,
|
||||
MINERU_API_MODE=config.MINERU_API_MODE,
|
||||
MINERU_API_URL=config.MINERU_API_URL,
|
||||
MINERU_API_KEY=config.MINERU_API_KEY,
|
||||
MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT,
|
||||
MINERU_PARAMS=config.MINERU_PARAMS,
|
||||
MINERU_FILE_EXTENSIONS=config.MINERU_FILE_EXTENSIONS,
|
||||
return get_web_loader(
|
||||
url,
|
||||
verify_ssl=config.get('web_loader_ssl_verification'),
|
||||
requests_per_second=config.get('web_loader_concurrent_requests'),
|
||||
trust_env=config.get('web_search_trust_env'),
|
||||
)
|
||||
|
||||
|
||||
def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]:
|
||||
def build_loader_from_config(request, config: dict):
|
||||
"""Build a Loader instance with the admin's configured extraction engine settings."""
|
||||
from open_webui.retrieval.loaders.main import Loader
|
||||
|
||||
loader_config = {
|
||||
key: config.get(key)
|
||||
for key in LOADER_CONFIG_KEYS
|
||||
if key.isupper()
|
||||
}
|
||||
return Loader(
|
||||
engine=loader_config['CONTENT_EXTRACTION_ENGINE'],
|
||||
**{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'},
|
||||
)
|
||||
|
||||
|
||||
def _extract_text_from_binary_response(request, response: requests.Response, url: str, loader_config: dict) -> tuple[str, list]:
|
||||
"""Download response body to a temp file and extract text using the Loader pipeline."""
|
||||
import mimetypes
|
||||
import tempfile
|
||||
@@ -150,7 +170,7 @@ def _extract_text_from_binary_response(request, response: requests.Response, url
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
loader = build_loader_from_config(request)
|
||||
loader = build_loader_from_config(request, loader_config)
|
||||
docs = loader.load(filename, content_type, tmp_path)
|
||||
for doc in docs:
|
||||
doc.metadata['source'] = url
|
||||
@@ -170,9 +190,11 @@ def _is_text_content_type(content_type: str) -> bool:
|
||||
return not ct # empty / missing → assume HTML
|
||||
|
||||
|
||||
def get_content_from_url(request, url: str) -> str:
|
||||
async def get_content_from_url(request, url: str) -> str:
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
|
||||
loader_config = await get_loader_config()
|
||||
|
||||
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
|
||||
validate_url(url)
|
||||
|
||||
@@ -183,7 +205,7 @@ def get_content_from_url(request, url: str) -> str:
|
||||
# when allow_redirects=False, causing the binary-content path to run
|
||||
# and produce empty docs → HTTP 400.
|
||||
if is_youtube_url(url):
|
||||
loader = get_loader(request, url)
|
||||
loader = get_loader(request, url, loader_config)
|
||||
docs = loader.load()
|
||||
content = ' '.join([doc.page_content for doc in docs])
|
||||
return content, docs
|
||||
@@ -205,14 +227,14 @@ def get_content_from_url(request, url: str) -> str:
|
||||
if response is None or _is_text_content_type(content_type):
|
||||
if response is not None:
|
||||
response.close()
|
||||
loader = get_loader(request, url)
|
||||
loader = get_loader(request, url, loader_config)
|
||||
docs = loader.load()
|
||||
content = ' '.join([doc.page_content for doc in docs])
|
||||
return content, docs
|
||||
|
||||
# Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract
|
||||
try:
|
||||
return _extract_text_from_binary_response(request, response, url)
|
||||
return _extract_text_from_binary_response(request, response, url, loader_config)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
@@ -539,8 +561,15 @@ async def query_collection(
|
||||
embedding_function,
|
||||
k: int,
|
||||
) -> dict:
|
||||
config = await Config.get_many(
|
||||
'rag.enable_hybrid_search',
|
||||
'rag.top_k_reranker',
|
||||
'rag.relevance_threshold',
|
||||
'rag.hybrid_bm25_weight',
|
||||
'rag.enable_hybrid_search_enriched_texts',
|
||||
)
|
||||
# When request is provided, try hybrid search + reranking if enabled
|
||||
if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
|
||||
if request and config.get('rag.enable_hybrid_search'):
|
||||
try:
|
||||
reranking_function = (
|
||||
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents))
|
||||
@@ -553,10 +582,10 @@ async def query_collection(
|
||||
embedding_function=embedding_function,
|
||||
k=k,
|
||||
reranking_function=reranking_function,
|
||||
k_reranker=request.app.state.config.TOP_K_RERANKER,
|
||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
|
||||
k_reranker=config.get('rag.top_k_reranker'),
|
||||
r=config.get('rag.relevance_threshold'),
|
||||
hybrid_bm25_weight=config.get('rag.hybrid_bm25_weight'),
|
||||
enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
|
||||
@@ -1165,6 +1194,7 @@ async def get_sources_from_items(
|
||||
):
|
||||
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
|
||||
|
||||
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
|
||||
extracted_collections = []
|
||||
query_results = []
|
||||
|
||||
@@ -1244,14 +1274,14 @@ async def get_sources_from_items(
|
||||
}
|
||||
|
||||
elif item.get('type') == 'url':
|
||||
content, docs = get_content_from_url(request, item.get('url'))
|
||||
content, docs = await get_content_from_url(request, item.get('url'))
|
||||
if docs:
|
||||
query_result = {
|
||||
'documents': [[content]],
|
||||
'metadatas': [[{'url': item.get('url'), 'name': item.get('url')}]],
|
||||
}
|
||||
elif item.get('type') == 'file':
|
||||
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
|
||||
if item.get('file', {}).get('data', {}).get('content', ''):
|
||||
# Manual Full Mode Toggle
|
||||
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
|
||||
@@ -1323,7 +1353,7 @@ async def get_sources_from_items(
|
||||
permission='read',
|
||||
)
|
||||
):
|
||||
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
|
||||
if knowledge_base and (
|
||||
user.role == 'admin'
|
||||
or knowledge_base.user_id == user.id
|
||||
|
||||
@@ -27,9 +27,15 @@ from open_webui.retrieval.vector.main import (
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from pymilvus import Collection, DataType, FieldSchema, connections
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Milvus caps stored text length (here the chunk lives under the JSON `data`
|
||||
# field). Clamp long chunks before insert so one oversized chunk can't fail the
|
||||
# whole batch and leave the file with zero embeddings.
|
||||
MILVUS_TEXT_MAX_LENGTH = 65535
|
||||
|
||||
|
||||
class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
@@ -270,18 +276,34 @@ class MilvusClient(VectorDBBase):
|
||||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
|
||||
return self.client.insert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
data=[
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
f'Milvus: truncating text id={item["id"]} '
|
||||
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars'
|
||||
)
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
data.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'data': {'text': item['text']},
|
||||
'data': {'text': text},
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
)
|
||||
)
|
||||
try:
|
||||
return self.client.insert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
data=data,
|
||||
)
|
||||
except MilvusException as e:
|
||||
log.error(
|
||||
f'Milvus insert failed for {self.collection_prefix}_{collection_name} '
|
||||
f'({len(items)} items): {e}'
|
||||
)
|
||||
raise
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
|
||||
@@ -298,18 +320,34 @@ class MilvusClient(VectorDBBase):
|
||||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
|
||||
return self.client.upsert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
data=[
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
f'Milvus: truncating text id={item["id"]} '
|
||||
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars'
|
||||
)
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
data.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'data': {'text': item['text']},
|
||||
'data': {'text': text},
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
)
|
||||
)
|
||||
try:
|
||||
return self.client.upsert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
data=data,
|
||||
)
|
||||
except MilvusException as e:
|
||||
log.error(
|
||||
f'Milvus upsert failed for {self.collection_prefix}_{collection_name} '
|
||||
f'({len(items)} items): {e}'
|
||||
)
|
||||
raise
|
||||
|
||||
def delete(
|
||||
self,
|
||||
|
||||
@@ -31,10 +31,15 @@ from pymilvus import (
|
||||
connections,
|
||||
utility,
|
||||
)
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
RESOURCE_ID_FIELD = 'resource_id'
|
||||
# Milvus VARCHAR hard cap for the `text` field (see _create_shared_collection).
|
||||
# Chunks longer than this are truncated before insert so one oversized chunk
|
||||
# can't fail the whole batch (and leave the file with zero embeddings).
|
||||
MILVUS_TEXT_MAX_LENGTH = 65535
|
||||
|
||||
# Milvus expressions are SQL-like strings with no parameterized-query API;
|
||||
# values get interpolated into single-quoted literals. Reject anything that
|
||||
@@ -169,17 +174,34 @@ class MilvusClient(VectorDBBase):
|
||||
self._ensure_collection(mt_collection, dimension)
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
entities = [
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'text': item['text'],
|
||||
'metadata': item['metadata'],
|
||||
RESOURCE_ID_FIELD: resource_id,
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
collection.insert(entities)
|
||||
entities = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
f'Milvus: truncating text id={item["id"]} '
|
||||
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars '
|
||||
f'(collection={mt_collection}, resource_id={resource_id})'
|
||||
)
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
entities.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'text': text,
|
||||
'metadata': item['metadata'],
|
||||
RESOURCE_ID_FIELD: resource_id,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
collection.insert(entities)
|
||||
except MilvusException as e:
|
||||
log.error(
|
||||
f'Milvus insert failed (collection={mt_collection}, '
|
||||
f'resource_id={resource_id}, items={len(entities)}): {e}'
|
||||
)
|
||||
raise
|
||||
|
||||
def search(
|
||||
self,
|
||||
|
||||
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import validators
|
||||
from open_webui.retrieval.web.utils import resolve_hostname
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.misc import is_host_allowed
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def get_filtered_results(results, filter_list):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_string_allowed(hostnames, filter_list):
|
||||
if is_host_allowed(hostnames, filter_list):
|
||||
filtered_results.append(result)
|
||||
continue
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ def search_perplexity(
|
||||
|
||||
"""
|
||||
|
||||
# Handle ConfigVar object
|
||||
if hasattr(api_key, '__str__'):
|
||||
api_key = str(api_key)
|
||||
api_key = str(api_key)
|
||||
|
||||
try:
|
||||
url = 'https://api.perplexity.ai/chat/completions'
|
||||
|
||||
@@ -29,12 +29,8 @@ def search_perplexity_search(
|
||||
|
||||
"""
|
||||
|
||||
# Handle ConfigVar object
|
||||
if hasattr(api_key, '__str__'):
|
||||
api_key = str(api_key)
|
||||
|
||||
if hasattr(api_url, '__str__'):
|
||||
api_url = str(api_url)
|
||||
api_key = str(api_key)
|
||||
api_url = str(api_url)
|
||||
|
||||
try:
|
||||
url = api_url
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import (
|
||||
import aiohttp
|
||||
import aiohttp.resolver
|
||||
import certifi
|
||||
import requests
|
||||
import urllib3.connection
|
||||
import urllib3.connectionpool
|
||||
import validators
|
||||
@@ -46,11 +45,16 @@ from open_webui.config import (
|
||||
WEB_LOADER_TIMEOUT,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, USER_AGENT
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
USER_AGENT,
|
||||
)
|
||||
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
|
||||
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
||||
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.misc import is_host_allowed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,7 +92,9 @@ def validate_url(url: Union[str, Sequence[str]]):
|
||||
|
||||
# Blocklist check using unified filtering logic
|
||||
if WEB_FETCH_FILTER_LIST:
|
||||
if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):
|
||||
# Match on the parsed hostname, not the full URL: a path component would
|
||||
# otherwise let any URL slip past a hostname-based block/allow entry.
|
||||
if not is_host_allowed(parsed_url.hostname, WEB_FETCH_FILTER_LIST):
|
||||
log.warning(f'URL blocked by filter list: {url}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
@@ -197,6 +203,19 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
|
||||
return results
|
||||
|
||||
|
||||
def get_ssrf_safe_session() -> aiohttp.ClientSession:
|
||||
"""A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver,
|
||||
defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must
|
||||
not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed:
|
||||
``async with get_ssrf_safe_session() as session: ...``.
|
||||
"""
|
||||
return aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()),
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
trust_env=True,
|
||||
)
|
||||
|
||||
|
||||
def extract_metadata(soup, url):
|
||||
metadata = {'source': url}
|
||||
if title := soup.find('title'):
|
||||
@@ -757,13 +776,13 @@ def get_web_loader(
|
||||
'trust_env': trust_env,
|
||||
}
|
||||
|
||||
if WEB_LOADER_ENGINE.value == '' or WEB_LOADER_ENGINE.value == 'safe_web':
|
||||
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
|
||||
request_kwargs = {}
|
||||
if WEB_LOADER_TIMEOUT.value:
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
try:
|
||||
timeout_value = float(WEB_LOADER_TIMEOUT.value)
|
||||
timeout_value = float(WEB_LOADER_TIMEOUT)
|
||||
except ValueError:
|
||||
timeout_value = None
|
||||
|
||||
@@ -773,31 +792,31 @@ def get_web_loader(
|
||||
if request_kwargs:
|
||||
web_loader_args['requests_kwargs'] = request_kwargs
|
||||
|
||||
if WEB_LOADER_ENGINE.value == 'playwright':
|
||||
if WEB_LOADER_ENGINE == 'playwright':
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT.value
|
||||
if PLAYWRIGHT_WS_URL.value:
|
||||
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL.value
|
||||
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
|
||||
if PLAYWRIGHT_WS_URL:
|
||||
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
|
||||
|
||||
if WEB_LOADER_ENGINE.value == 'firecrawl':
|
||||
if WEB_LOADER_ENGINE == 'firecrawl':
|
||||
WebLoaderClass = SafeFireCrawlLoader
|
||||
web_loader_args['api_key'] = FIRECRAWL_API_KEY.value
|
||||
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL.value
|
||||
if FIRECRAWL_TIMEOUT.value:
|
||||
web_loader_args['api_key'] = FIRECRAWL_API_KEY
|
||||
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
|
||||
if FIRECRAWL_TIMEOUT:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT.value)
|
||||
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE.value == 'tavily':
|
||||
if WEB_LOADER_ENGINE == 'tavily':
|
||||
WebLoaderClass = SafeTavilyLoader
|
||||
web_loader_args['api_key'] = TAVILY_API_KEY.value
|
||||
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH.value
|
||||
web_loader_args['api_key'] = TAVILY_API_KEY
|
||||
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
|
||||
|
||||
if WEB_LOADER_ENGINE.value == 'external':
|
||||
if WEB_LOADER_ENGINE == 'external':
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL.value
|
||||
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY.value
|
||||
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
|
||||
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
|
||||
|
||||
if WebLoaderClass:
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
@@ -811,6 +830,6 @@ def get_web_loader(
|
||||
return web_loader
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. '
|
||||
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
|
||||
"Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'."
|
||||
)
|
||||
|
||||
@@ -28,6 +28,8 @@ router = APIRouter()
|
||||
class ModelAnalyticsEntry(BaseModel):
|
||||
model_id: str
|
||||
count: int
|
||||
unique_users: int = 0
|
||||
unique_chats: int = 0
|
||||
|
||||
|
||||
class ModelAnalyticsResponse(BaseModel):
|
||||
@@ -65,8 +67,16 @@ async def get_model_analytics(
|
||||
counts = await ChatMessages.get_message_count_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
unique_counts = await ChatMessages.get_unique_counts_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
models = [
|
||||
ModelAnalyticsEntry(model_id=model_id, count=count)
|
||||
ModelAnalyticsEntry(
|
||||
model_id=model_id,
|
||||
count=count,
|
||||
unique_users=unique_counts.get(model_id, {}).get('unique_users', 0),
|
||||
unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0),
|
||||
)
|
||||
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
|
||||
]
|
||||
return ModelAnalyticsResponse(models=models)
|
||||
@@ -367,6 +377,12 @@ async def get_model_overview(
|
||||
):
|
||||
"""Get model overview with feedback history and chat tags."""
|
||||
|
||||
# Calculate start date for history
|
||||
now = datetime.now()
|
||||
start_dt = None
|
||||
if days > 0:
|
||||
start_dt = now - timedelta(days=days)
|
||||
|
||||
# Get chat IDs that used this model
|
||||
chat_ids = await ChatMessages.get_chat_ids_by_model_id(
|
||||
model_id=model_id,
|
||||
@@ -377,31 +393,18 @@ async def get_model_overview(
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Get feedback history per day
|
||||
history_counts: dict[str, dict] = defaultdict(lambda: {'won': 0, 'lost': 0})
|
||||
|
||||
# Calculate start date for history
|
||||
now = datetime.now()
|
||||
start_dt = None
|
||||
if days > 0:
|
||||
start_dt = now - timedelta(days=days)
|
||||
|
||||
for chat_id in chat_ids:
|
||||
feedbacks = await Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
|
||||
for fb in feedbacks:
|
||||
if fb.data and 'rating' in fb.data:
|
||||
rating = fb.data['rating']
|
||||
fb_date = datetime.fromtimestamp(fb.created_at)
|
||||
|
||||
# Filter by date range
|
||||
if start_dt and fb_date < start_dt:
|
||||
continue
|
||||
|
||||
date_str = fb_date.strftime('%Y-%m-%d')
|
||||
if rating == 1:
|
||||
history_counts[date_str]['won'] += 1
|
||||
elif rating == -1:
|
||||
history_counts[date_str]['lost'] += 1
|
||||
history_rows = await Feedbacks.get_model_feedback_counts_by_day(
|
||||
model_id=model_id,
|
||||
start_date=int(start_dt.timestamp()) if start_dt else None,
|
||||
db=db,
|
||||
)
|
||||
history_counts = {
|
||||
entry.date: {
|
||||
'won': entry.won,
|
||||
'lost': entry.lost,
|
||||
}
|
||||
for entry in history_rows
|
||||
}
|
||||
|
||||
# Fill in missing days
|
||||
history = []
|
||||
@@ -430,10 +433,14 @@ async def get_model_overview(
|
||||
|
||||
# Get chat tags
|
||||
tag_counts: dict[str, int] = defaultdict(int)
|
||||
for chat_id in chat_ids:
|
||||
chat = await Chats.get_chat_by_id(chat_id, db=db)
|
||||
if chat and chat.meta:
|
||||
for tag in chat.meta.get('tags', []):
|
||||
if chat_ids:
|
||||
chat_metas = await Chats.get_chat_metas_by_chat_ids(
|
||||
chat_ids,
|
||||
include_archived=True,
|
||||
db=db,
|
||||
)
|
||||
for meta in chat_metas:
|
||||
for tag in meta.get('tags', []):
|
||||
tag_counts[tag] += 1
|
||||
|
||||
# Sort by count and take top 10
|
||||
|
||||
+119
-157
@@ -52,6 +52,7 @@ from open_webui.env import (
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
ENV,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
@@ -71,6 +72,50 @@ AZURE_MAX_FILE_SIZE: int = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech'
|
||||
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TTS_CONFIG_KEYS = {
|
||||
'OPENAI_API_BASE_URL': 'audio.tts.openai.api_base_url',
|
||||
'OPENAI_API_KEY': 'audio.tts.openai.api_key',
|
||||
'OPENAI_PARAMS': 'audio.tts.openai.params',
|
||||
'API_KEY': 'audio.tts.api_key',
|
||||
'ENGINE': 'audio.tts.engine',
|
||||
'MODEL': 'audio.tts.model',
|
||||
'VOICE': 'audio.tts.voice',
|
||||
'SPLIT_ON': 'audio.tts.split_on',
|
||||
'AZURE_SPEECH_REGION': 'audio.tts.azure.speech_region',
|
||||
'AZURE_SPEECH_BASE_URL': 'audio.tts.azure.speech_base_url',
|
||||
'AZURE_SPEECH_OUTPUT_FORMAT': 'audio.tts.azure.speech_output_format',
|
||||
'MISTRAL_API_KEY': 'audio.tts.mistral.api_key',
|
||||
'MISTRAL_API_BASE_URL': 'audio.tts.mistral.api_base_url',
|
||||
}
|
||||
|
||||
STT_CONFIG_KEYS = {
|
||||
'OPENAI_API_BASE_URL': 'audio.stt.openai.api_base_url',
|
||||
'OPENAI_API_KEY': 'audio.stt.openai.api_key',
|
||||
'ENGINE': 'audio.stt.engine',
|
||||
'MODEL': 'audio.stt.model',
|
||||
'SUPPORTED_CONTENT_TYPES': 'audio.stt.supported_content_types',
|
||||
'ALLOWED_EXTENSIONS': 'audio.stt.allowed_extensions',
|
||||
'WHISPER_MODEL': 'audio.stt.whisper_model',
|
||||
'DEEPGRAM_API_KEY': 'audio.stt.deepgram.api_key',
|
||||
'AZURE_API_KEY': 'audio.stt.azure.api_key',
|
||||
'AZURE_REGION': 'audio.stt.azure.region',
|
||||
'AZURE_LOCALES': 'audio.stt.azure.locales',
|
||||
'AZURE_BASE_URL': 'audio.stt.azure.base_url',
|
||||
'AZURE_MAX_SPEAKERS': 'audio.stt.azure.max_speakers',
|
||||
'MISTRAL_API_KEY': 'audio.stt.mistral.api_key',
|
||||
'MISTRAL_API_BASE_URL': 'audio.stt.mistral.api_base_url',
|
||||
'MISTRAL_USE_CHAT_COMPLETIONS': 'audio.stt.mistral.use_chat_completions',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
return {key_map[field]: value for field, value in data.items() if field in key_map}
|
||||
|
||||
|
||||
def is_audio_conversion_required(file_path):
|
||||
"""
|
||||
@@ -228,119 +273,28 @@ class AudioConfigUpdateForm(BaseModel):
|
||||
@router.get('/config')
|
||||
async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'tts': {
|
||||
'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY,
|
||||
'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS,
|
||||
'API_KEY': request.app.state.config.TTS_API_KEY,
|
||||
'ENGINE': request.app.state.config.TTS_ENGINE,
|
||||
'MODEL': request.app.state.config.TTS_MODEL,
|
||||
'VOICE': request.app.state.config.TTS_VOICE,
|
||||
'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON,
|
||||
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
||||
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
|
||||
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
|
||||
},
|
||||
'stt': {
|
||||
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
|
||||
'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY,
|
||||
'ENGINE': request.app.state.config.STT_ENGINE,
|
||||
'MODEL': request.app.state.config.STT_MODEL,
|
||||
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
|
||||
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
|
||||
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
|
||||
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION,
|
||||
'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||
'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||
'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||
'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
|
||||
'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
|
||||
'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
|
||||
},
|
||||
'tts': await get_config_values(TTS_CONFIG_KEYS),
|
||||
'stt': await get_config_values(STT_CONFIG_KEYS),
|
||||
}
|
||||
|
||||
|
||||
@router.post('/config/update')
|
||||
async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)):
|
||||
# TTS settings
|
||||
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
|
||||
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
|
||||
request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
|
||||
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
|
||||
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
|
||||
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
|
||||
request.app.state.config.TTS_VOICE = form_data.tts.VOICE
|
||||
request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON
|
||||
request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION
|
||||
request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL
|
||||
request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT
|
||||
request.app.state.config.TTS_MISTRAL_API_KEY = form_data.tts.MISTRAL_API_KEY
|
||||
request.app.state.config.TTS_MISTRAL_API_BASE_URL = form_data.tts.MISTRAL_API_BASE_URL
|
||||
await Config.upsert(
|
||||
{
|
||||
**config_updates(form_data.tts.model_dump(), TTS_CONFIG_KEYS),
|
||||
**config_updates(form_data.stt.model_dump(), STT_CONFIG_KEYS),
|
||||
}
|
||||
)
|
||||
|
||||
# STT settings
|
||||
request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL
|
||||
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
|
||||
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
|
||||
request.app.state.config.STT_MODEL = form_data.stt.MODEL
|
||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES
|
||||
request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS
|
||||
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
|
||||
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
|
||||
request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY
|
||||
request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION
|
||||
request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES
|
||||
request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL
|
||||
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = form_data.stt.AZURE_MAX_SPEAKERS
|
||||
request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY
|
||||
request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = form_data.stt.MISTRAL_API_BASE_URL
|
||||
request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS
|
||||
|
||||
if request.app.state.config.STT_ENGINE == '':
|
||||
if form_data.stt.ENGINE == '':
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(
|
||||
form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
|
||||
)
|
||||
else:
|
||||
request.app.state.faster_whisper_model = None
|
||||
|
||||
return {
|
||||
'tts': {
|
||||
'ENGINE': request.app.state.config.TTS_ENGINE,
|
||||
'MODEL': request.app.state.config.TTS_MODEL,
|
||||
'VOICE': request.app.state.config.TTS_VOICE,
|
||||
'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY,
|
||||
'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS,
|
||||
'API_KEY': request.app.state.config.TTS_API_KEY,
|
||||
'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON,
|
||||
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
|
||||
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
|
||||
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
|
||||
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
|
||||
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
|
||||
},
|
||||
'stt': {
|
||||
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
|
||||
'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY,
|
||||
'ENGINE': request.app.state.config.STT_ENGINE,
|
||||
'MODEL': request.app.state.config.STT_MODEL,
|
||||
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
|
||||
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
|
||||
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
|
||||
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION,
|
||||
'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES,
|
||||
'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
|
||||
'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
|
||||
'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
|
||||
'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
|
||||
'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
|
||||
},
|
||||
}
|
||||
return await get_audio_config(request, user)
|
||||
|
||||
|
||||
def load_speech_pipeline(request):
|
||||
@@ -388,14 +342,16 @@ async def _write_tts_cache(
|
||||
|
||||
async def _tts_openai(request, payload, file_path, file_body_path, user):
|
||||
"""Generate speech via an OpenAI-compatible TTS endpoint."""
|
||||
payload['model'] = request.app.state.config.TTS_MODEL
|
||||
payload['model'] = await Config.get('audio.tts.model')
|
||||
if not payload.get('voice'):
|
||||
payload['voice'] = request.app.state.config.TTS_VOICE
|
||||
payload = {**payload, **(request.app.state.config.TTS_OPENAI_PARAMS or {})}
|
||||
payload['voice'] = await Config.get('audio.tts.voice')
|
||||
payload = {**payload, **(await Config.get('audio.tts.openai.params') or {})}
|
||||
api_key = await Config.get('audio.tts.openai.api_key')
|
||||
api_base_url = await Config.get('audio.tts.openai.api_base_url')
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {request.app.state.config.TTS_OPENAI_API_KEY}',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
@@ -404,7 +360,7 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
|
||||
try:
|
||||
session = await get_session()
|
||||
r = await session.post(
|
||||
url=f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech',
|
||||
url=f'{api_base_url}/audio/speech',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -429,8 +385,12 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
|
||||
|
||||
async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
|
||||
"""Generate speech via the ElevenLabs TTS API."""
|
||||
voice_id = payload.get('voice', '')
|
||||
if voice_id not in await get_available_voices(request):
|
||||
voice_id = (payload.get('voice') or '').strip()
|
||||
if not voice_id:
|
||||
raise HTTPException(status_code=400, detail='Invalid voice id')
|
||||
|
||||
available_voices = await get_available_voices(request)
|
||||
if available_voices and voice_id not in available_voices:
|
||||
raise HTTPException(status_code=400, detail='Invalid voice id')
|
||||
|
||||
r = None
|
||||
@@ -440,13 +400,13 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}',
|
||||
json={
|
||||
'text': payload['input'],
|
||||
'model_id': request.app.state.config.TTS_MODEL,
|
||||
'model_id': await Config.get('audio.tts.model'),
|
||||
'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5},
|
||||
},
|
||||
headers={
|
||||
'Accept': 'audio/mpeg',
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': request.app.state.config.TTS_API_KEY,
|
||||
'xi-api-key': await Config.get('audio.tts.api_key'),
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
@@ -460,11 +420,11 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
|
||||
|
||||
async def _tts_azure(request, payload, file_path, file_body_path, user):
|
||||
"""Generate speech via Azure Cognitive Services TTS."""
|
||||
az_region = request.app.state.config.TTS_AZURE_SPEECH_REGION or 'eastus'
|
||||
az_base = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
|
||||
language = payload.get('voice') or request.app.state.config.TTS_VOICE
|
||||
az_region = await Config.get('audio.tts.azure.speech_region') or 'eastus'
|
||||
az_base = await Config.get('audio.tts.azure.speech_base_url')
|
||||
language = payload.get('voice') or await Config.get('audio.tts.voice')
|
||||
locale = '-'.join(language.split('-')[:2])
|
||||
output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT
|
||||
output_format = await Config.get('audio.tts.azure.speech_output_format')
|
||||
|
||||
ssml = (
|
||||
f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">'
|
||||
@@ -478,7 +438,7 @@ async def _tts_azure(request, payload, file_path, file_body_path, user):
|
||||
async with session.post(
|
||||
(az_base or f'https://{az_region}.tts.speech.microsoft.com') + '/cognitiveservices/v1',
|
||||
headers={
|
||||
'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY,
|
||||
'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key'),
|
||||
'Content-Type': 'application/ssml+xml',
|
||||
'X-Microsoft-OutputFormat': output_format,
|
||||
},
|
||||
@@ -501,7 +461,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
|
||||
load_speech_pipeline(request)
|
||||
|
||||
embeddings = request.app.state.speech_speaker_embeddings_dataset
|
||||
model_name = request.app.state.config.TTS_MODEL
|
||||
model_name = await Config.get('audio.tts.model')
|
||||
|
||||
idx = 6799
|
||||
try:
|
||||
@@ -529,8 +489,8 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
|
||||
|
||||
async def _tts_mistral(request, payload, file_path, file_body_path, user):
|
||||
"""Generate speech via the Mistral TTS API."""
|
||||
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
|
||||
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
|
||||
api_key = await Config.get('audio.tts.mistral.api_key')
|
||||
api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral TTS')
|
||||
@@ -542,7 +502,7 @@ async def _tts_mistral(request, payload, file_path, file_body_path, user):
|
||||
url=f'{api_base_url}/audio/speech',
|
||||
json={
|
||||
'input': payload.get('input', ''), # text to synthesize
|
||||
'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603',
|
||||
'model': await Config.get('audio.tts.model') or 'voxtral-mini-tts-2603',
|
||||
'voice_id': payload.get('voice', ''),
|
||||
'response_format': 'mp3',
|
||||
},
|
||||
@@ -578,7 +538,7 @@ _TTS_ENGINES = {
|
||||
|
||||
@router.post('/speech')
|
||||
async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
engine = request.app.state.config.TTS_ENGINE
|
||||
engine = await Config.get('audio.tts.engine')
|
||||
if engine == '':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -586,7 +546,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'chat.tts', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -595,7 +555,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(await Config.get('audio.tts.model')).encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3')
|
||||
@@ -620,7 +580,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
async def _transcribe_whisper(request, file_path, languages, file_dir, id):
|
||||
if request.app.state.faster_whisper_model is None:
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL)
|
||||
request.app.state.faster_whisper_model = set_faster_whisper_model(await Config.get('audio.stt.whisper_model'))
|
||||
|
||||
model = request.app.state.faster_whisper_model
|
||||
|
||||
@@ -651,11 +611,13 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
||||
try:
|
||||
session = await get_session()
|
||||
for language in languages:
|
||||
payload = {'model': request.app.state.config.STT_MODEL}
|
||||
payload = {'model': await Config.get('audio.stt.model')}
|
||||
if language:
|
||||
payload['language'] = language
|
||||
api_key = await Config.get('audio.stt.openai.api_key')
|
||||
api_base_url = await Config.get('audio.stt.openai.api_base_url')
|
||||
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'}
|
||||
headers = {'Authorization': f'Bearer {api_key}'}
|
||||
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
@@ -665,7 +627,7 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
||||
form_data.add_field('file', open(file_path, 'rb'), filename=filename)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions',
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -699,8 +661,8 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
audio_bytes = await f.read()
|
||||
|
||||
api_key = request.app.state.config.DEEPGRAM_API_KEY
|
||||
stt_model = request.app.state.config.STT_MODEL
|
||||
api_key = await Config.get('audio.stt.deepgram.api_key')
|
||||
stt_model = await Config.get('audio.stt.model')
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -767,11 +729,11 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
||||
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
|
||||
region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus'
|
||||
locale_str = request.app.state.config.AUDIO_STT_AZURE_LOCALES
|
||||
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
|
||||
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3
|
||||
api_key = await Config.get('audio.stt.azure.api_key')
|
||||
region = await Config.get('audio.stt.azure.region') or 'eastus'
|
||||
locale_str = await Config.get('audio.stt.azure.locales')
|
||||
base_url = await Config.get('audio.stt.azure.base_url')
|
||||
max_speakers = await Config.get('audio.stt.azure.max_speakers') or 3
|
||||
|
||||
# Default to a broad set of locales when none are configured
|
||||
if len(locale_str) < 2:
|
||||
@@ -881,16 +843,16 @@ async def transcription_handler(request, file_path, metadata, user=None):
|
||||
None, # Always fallback to None in case transcription fails
|
||||
]
|
||||
|
||||
if request.app.state.config.STT_ENGINE == '':
|
||||
if await Config.get('audio.stt.engine') == '':
|
||||
return await _transcribe_whisper(request, file_path, languages, file_dir, id)
|
||||
elif request.app.state.config.STT_ENGINE == 'openai':
|
||||
elif await Config.get('audio.stt.engine') == 'openai':
|
||||
return await _transcribe_openai(request, file_path, filename, languages, file_dir, id, user)
|
||||
elif request.app.state.config.STT_ENGINE == 'deepgram':
|
||||
elif await Config.get('audio.stt.engine') == 'deepgram':
|
||||
return await _transcribe_deepgram(request, file_path, languages, file_dir, id)
|
||||
elif request.app.state.config.STT_ENGINE == 'azure':
|
||||
elif await Config.get('audio.stt.engine') == 'azure':
|
||||
return await _transcribe_azure(request, file_path, filename, file_dir, id)
|
||||
|
||||
elif request.app.state.config.STT_ENGINE == 'mistral':
|
||||
elif await Config.get('audio.stt.engine') == 'mistral':
|
||||
return await _transcribe_mistral(request, file_path, filename, metadata, file_dir, id)
|
||||
|
||||
|
||||
@@ -903,16 +865,16 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB')
|
||||
|
||||
api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
|
||||
api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
|
||||
use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
|
||||
api_key = await Config.get('audio.stt.mistral.api_key')
|
||||
api_base_url = await Config.get('audio.stt.mistral.api_base_url') or 'https://api.mistral.ai/v1'
|
||||
use_chat_completions = await Config.get('audio.stt.mistral.use_chat_completions')
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral STT')
|
||||
|
||||
r = None
|
||||
try:
|
||||
model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest'
|
||||
model = await Config.get('audio.stt.model') or 'voxtral-mini-latest'
|
||||
log.info(
|
||||
f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}'
|
||||
)
|
||||
@@ -1154,14 +1116,14 @@ async def transcription(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'chat.stt', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
log.info(f'file.content_type: {file.content_type}')
|
||||
stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
|
||||
stt_supported_content_types = await Config.get('audio.stt.supported_content_types', [])
|
||||
|
||||
if not strict_match_mime_type(stt_supported_content_types, file.content_type):
|
||||
raise HTTPException(
|
||||
@@ -1173,7 +1135,7 @@ async def transcription(
|
||||
safe_name = os.path.basename(file.filename) if file.filename else ''
|
||||
ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else ''
|
||||
|
||||
allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', [])
|
||||
allowed_extensions = await Config.get('audio.stt.allowed_extensions', [])
|
||||
if allowed_extensions and ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1233,11 +1195,11 @@ async def transcription(
|
||||
async def get_available_models(request: Request) -> list[dict]:
|
||||
"""Return the list of available TTS models for the configured engine."""
|
||||
available_models = []
|
||||
engine = request.app.state.config.TTS_ENGINE
|
||||
engine = await Config.get('audio.tts.engine')
|
||||
_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
|
||||
if engine == 'openai':
|
||||
base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL
|
||||
base_url = await Config.get('audio.tts.openai.api_base_url')
|
||||
if not base_url.startswith('https://api.openai.com'):
|
||||
session = await get_session()
|
||||
try:
|
||||
@@ -1272,7 +1234,7 @@ async def get_available_models(request: Request) -> list[dict]:
|
||||
async with session.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/models',
|
||||
headers={
|
||||
'xi-api-key': request.app.state.config.TTS_API_KEY,
|
||||
'xi-api-key': await Config.get('audio.tts.api_key'),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -1307,11 +1269,11 @@ _OPENAI_DEFAULT_VOICES = {
|
||||
|
||||
async def get_available_voices(request) -> dict:
|
||||
"""Return ``{voice_id: voice_name}`` for the configured TTS engine."""
|
||||
engine = request.app.state.config.TTS_ENGINE
|
||||
engine = await Config.get('audio.tts.engine')
|
||||
_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
|
||||
if engine == 'openai':
|
||||
base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL
|
||||
base_url = await Config.get('audio.tts.openai.api_base_url')
|
||||
if not base_url.startswith('https://api.openai.com'):
|
||||
try:
|
||||
session = await get_session()
|
||||
@@ -1334,7 +1296,7 @@ async def get_available_voices(request) -> dict:
|
||||
async with session.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
|
||||
headers={
|
||||
'xi-api-key': request.app.state.config.TTS_API_KEY,
|
||||
'xi-api-key': await Config.get('audio.tts.api_key'),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -1344,19 +1306,19 @@ async def get_available_voices(request) -> dict:
|
||||
voices_data = await resp.json()
|
||||
return {v['voice_id']: v['name'] for v in voices_data.get('voices', [])}
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching ElevenLabs voices: {e}')
|
||||
log.warning(f'Error fetching ElevenLabs voices: {e}')
|
||||
return {}
|
||||
|
||||
if engine == 'azure':
|
||||
try:
|
||||
region = request.app.state.config.TTS_AZURE_SPEECH_REGION
|
||||
base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
|
||||
region = await Config.get('audio.tts.azure.speech_region')
|
||||
base_url = await Config.get('audio.tts.azure.speech_base_url')
|
||||
url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list'
|
||||
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url,
|
||||
headers={'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY},
|
||||
headers={'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key')},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=_timeout,
|
||||
) as resp:
|
||||
@@ -1368,8 +1330,8 @@ async def get_available_voices(request) -> dict:
|
||||
return {}
|
||||
|
||||
if engine == 'mistral':
|
||||
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
|
||||
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
|
||||
api_key = await Config.get('audio.tts.mistral.api_key')
|
||||
api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
|
||||
if api_key:
|
||||
try:
|
||||
session = await get_session()
|
||||
|
||||
+282
-205
@@ -8,21 +8,15 @@ import time
|
||||
import urllib
|
||||
import uuid
|
||||
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
|
||||
from typing import List, Optional
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from ldap3 import NONE, Connection, Server, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from open_webui.config import (
|
||||
ENABLE_LDAP,
|
||||
ENABLE_OAUTH_SIGNUP,
|
||||
ENABLE_PASSWORD_AUTH,
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
OAUTH_PROVIDERS,
|
||||
OPENID_END_SESSION_ENDPOINT,
|
||||
OPENID_PROVIDER_URL,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
@@ -50,6 +44,7 @@ from open_webui.models.auths import (
|
||||
Token,
|
||||
UpdatePasswordForm,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import (
|
||||
@@ -75,7 +70,6 @@ from open_webui.utils.auth import (
|
||||
)
|
||||
from open_webui.utils.groups import apply_default_group_assignment
|
||||
from open_webui.utils.misc import parse_duration, validate_email_format
|
||||
from open_webui.utils.oauth import auth_manager_config
|
||||
from open_webui.utils.rate_limit import RateLimiter
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
@@ -90,6 +84,60 @@ log = logging.getLogger(__name__)
|
||||
# who exceed their allotted rate against this gate.
|
||||
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
|
||||
|
||||
ADMIN_CONFIG_KEYS = {
|
||||
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
|
||||
'ADMIN_EMAIL': 'auth.admin.email',
|
||||
'WEBUI_URL': 'webui.url',
|
||||
'ENABLE_SIGNUP': 'ui.enable_signup',
|
||||
'ENABLE_API_KEYS': 'auth.enable_api_keys',
|
||||
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': 'auth.api_key.endpoint_restrictions',
|
||||
'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints',
|
||||
'DEFAULT_USER_ROLE': 'ui.default_user_role',
|
||||
'DEFAULT_GROUP_ID': 'ui.default_group_id',
|
||||
'JWT_EXPIRES_IN': 'auth.jwt_expiry',
|
||||
'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing',
|
||||
'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating',
|
||||
'ENABLE_FOLDERS': 'folders.enable',
|
||||
'FOLDER_MAX_FILE_COUNT': 'folders.max_file_count',
|
||||
'AUTOMATION_MAX_COUNT': 'automations.max_count',
|
||||
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
|
||||
'ENABLE_AUTOMATIONS': 'automations.enable',
|
||||
'ENABLE_CHANNELS': 'channels.enable',
|
||||
'ENABLE_CALENDAR': 'calendar.enable',
|
||||
'ENABLE_MEMORIES': 'memories.enable',
|
||||
'ENABLE_NOTES': 'notes.enable',
|
||||
'ENABLE_USER_WEBHOOKS': 'ui.enable_user_webhooks',
|
||||
'ENABLE_USER_STATUS': 'users.enable_status',
|
||||
'PENDING_USER_OVERLAY_TITLE': 'ui.pending_user_overlay_title',
|
||||
'PENDING_USER_OVERLAY_CONTENT': 'ui.pending_user_overlay_content',
|
||||
'RESPONSE_WATERMARK': 'ui.watermark',
|
||||
}
|
||||
|
||||
LDAP_SERVER_CONFIG_KEYS = {
|
||||
'label': 'ldap.server.label',
|
||||
'host': 'ldap.server.host',
|
||||
'port': 'ldap.server.port',
|
||||
'attribute_for_mail': 'ldap.server.attribute_for_mail',
|
||||
'attribute_for_username': 'ldap.server.attribute_for_username',
|
||||
'app_dn': 'ldap.server.app_dn',
|
||||
'app_dn_password': 'ldap.server.app_password',
|
||||
'search_base': 'ldap.server.users_dn',
|
||||
'search_filters': 'ldap.server.search_filter',
|
||||
'use_tls': 'ldap.server.use_tls',
|
||||
'certificate_path': 'ldap.server.ca_cert_file',
|
||||
'validate_cert': 'ldap.server.validate_cert',
|
||||
'ciphers': 'ldap.server.ciphers',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
return {key_map[field]: value for field, value in data.items() if field in key_map}
|
||||
|
||||
|
||||
async def create_session_response(
|
||||
request: Request, user, db, response: Response = None, set_cookie: bool = False
|
||||
@@ -105,7 +153,7 @@ async def create_session_response(
|
||||
response: FastAPI response object (required if set_cookie is True)
|
||||
set_cookie: Whether to set the auth cookie on the response
|
||||
"""
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
@@ -128,7 +176,7 @@ async def create_session_response(
|
||||
**({'max_age': max_age} if max_age is not None else {}),
|
||||
)
|
||||
|
||||
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
|
||||
|
||||
return {
|
||||
'token': token,
|
||||
@@ -201,7 +249,7 @@ async def get_session_user(
|
||||
**({'max_age': max_age} if max_age is not None else {}),
|
||||
)
|
||||
|
||||
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
|
||||
|
||||
response_data = {
|
||||
'token': token,
|
||||
@@ -320,7 +368,7 @@ async def ldap_auth(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
# Security checks FIRST - before loading any config
|
||||
if not request.app.state.config.ENABLE_LDAP:
|
||||
if not await Config.get('ldap.enable'):
|
||||
raise HTTPException(400, detail='LDAP authentication is not enabled')
|
||||
|
||||
if not ENABLE_PASSWORD_AUTH:
|
||||
@@ -338,19 +386,19 @@ async def ldap_auth(
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
# NOW load LDAP config variables
|
||||
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
|
||||
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
|
||||
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
|
||||
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
|
||||
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
|
||||
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
|
||||
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
|
||||
LDAP_APP_DN = request.app.state.config.LDAP_APP_DN
|
||||
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
|
||||
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
|
||||
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
|
||||
LDAP_VALIDATE_CERT = CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE
|
||||
LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL'
|
||||
LDAP_SERVER_LABEL = await Config.get('ldap.server.label')
|
||||
LDAP_SERVER_HOST = await Config.get('ldap.server.host')
|
||||
LDAP_SERVER_PORT = await Config.get('ldap.server.port')
|
||||
LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail')
|
||||
LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username')
|
||||
LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn')
|
||||
LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter')
|
||||
LDAP_APP_DN = await Config.get('ldap.server.app_dn')
|
||||
LDAP_APP_PASSWORD = await Config.get('ldap.server.app_password')
|
||||
LDAP_USE_TLS = await Config.get('ldap.server.use_tls')
|
||||
LDAP_CA_CERT_FILE = await Config.get('ldap.server.ca_cert_file')
|
||||
LDAP_VALIDATE_CERT = CERT_REQUIRED if await Config.get('ldap.server.validate_cert') else CERT_NONE
|
||||
LDAP_CIPHERS = await Config.get('ldap.server.ciphers') if await Config.get('ldap.server.ciphers') else 'ALL'
|
||||
|
||||
try:
|
||||
tls = Tls(
|
||||
@@ -381,9 +429,9 @@ async def ldap_auth(
|
||||
if not await asyncio.to_thread(connection_app.bind):
|
||||
raise HTTPException(400, detail='Application account bind failed')
|
||||
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION
|
||||
LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = await Config.get('ldap.group.enable_management')
|
||||
ENABLE_LDAP_GROUP_CREATION = await Config.get('ldap.group.enable_creation')
|
||||
LDAP_ATTRIBUTE_FOR_GROUPS = await Config.get('ldap.server.attribute_for_groups')
|
||||
|
||||
search_attributes = [
|
||||
f'{LDAP_ATTRIBUTE_FOR_USERNAME}',
|
||||
@@ -500,7 +548,7 @@ async def ldap_auth(
|
||||
email=email,
|
||||
password=str(uuid.uuid4()),
|
||||
name=cn,
|
||||
role=request.app.state.config.DEFAULT_USER_ROLE,
|
||||
role=await Config.get('ui.default_user_role'),
|
||||
db=db,
|
||||
)
|
||||
|
||||
@@ -514,15 +562,15 @@ async def ldap_auth(
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
|
||||
await apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
await Config.get('ui.default_group_id'),
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
if await Config.get('webhook_url'):
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
await Config.get('webhook_url'),
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
'action': 'signup',
|
||||
@@ -703,7 +751,7 @@ async def signup_handler(
|
||||
password=hashed,
|
||||
name=name,
|
||||
profile_image_url=profile_image_url,
|
||||
role=request.app.state.config.DEFAULT_USER_ROLE,
|
||||
role=await Config.get('ui.default_user_role'),
|
||||
db=db,
|
||||
)
|
||||
if not user:
|
||||
@@ -714,12 +762,12 @@ async def signup_handler(
|
||||
if await Users.get_num_users(db=db) == 1:
|
||||
await Users.update_user_role_by_id(user.id, 'admin', db=db)
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
await Config.upsert({'ui.enable_signup': False})
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
if await Config.get('webhook_url'):
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
await Config.get('webhook_url'),
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
'action': 'signup',
|
||||
@@ -729,7 +777,7 @@ async def signup_handler(
|
||||
)
|
||||
|
||||
await apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
await Config.get('ui.default_group_id'),
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
@@ -748,10 +796,10 @@ async def signup(
|
||||
|
||||
if WEBUI_AUTH:
|
||||
if has_users:
|
||||
if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM:
|
||||
if not await Config.get('ui.enable_signup') or not await Config.get('ui.enable_login_form'):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
# Don't gate the first admin on ENABLE_SIGNUP: it auto-disables and can persist stale across a DB reset.
|
||||
elif not request.app.state.config.ENABLE_LOGIN_FORM and not ENABLE_INITIAL_ADMIN_SIGNUP:
|
||||
elif not await Config.get('ui.enable_login_form') and not ENABLE_INITIAL_ADMIN_SIGNUP:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
else:
|
||||
if has_users:
|
||||
@@ -812,19 +860,21 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
|
||||
|
||||
# If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
|
||||
# there directly instead of attempting OIDC discovery.
|
||||
if OPENID_END_SESSION_ENDPOINT.value:
|
||||
openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint')
|
||||
if openid_end_session_endpoint:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
'status': True,
|
||||
'redirect_url': OPENID_END_SESSION_ENDPOINT.value,
|
||||
'redirect_url': openid_end_session_endpoint,
|
||||
},
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
openid_provider_url = await Config.get('oauth.provider_url')
|
||||
oauth_server_metadata_url = (
|
||||
request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None
|
||||
) or OPENID_PROVIDER_URL.value
|
||||
) or openid_provider_url
|
||||
|
||||
if session and oauth_server_metadata_url:
|
||||
oauth_id_token = session.token.get('id_token')
|
||||
@@ -934,12 +984,12 @@ async def add_user(
|
||||
|
||||
if user:
|
||||
await apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
await Config.get('ui.default_group_id'),
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
|
||||
token = create_token(data={'id': user.id}, expires_delta=expires_delta)
|
||||
return {
|
||||
'token': token,
|
||||
@@ -968,8 +1018,8 @@ async def add_user(
|
||||
async def get_admin_details(
|
||||
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if request.app.state.config.SHOW_ADMIN_DETAILS:
|
||||
admin_email = request.app.state.config.ADMIN_EMAIL
|
||||
if await Config.get('auth.admin.show'):
|
||||
admin_email = await Config.get('auth.admin.email')
|
||||
admin_name = None
|
||||
|
||||
log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}')
|
||||
@@ -999,34 +1049,7 @@ async def get_admin_details(
|
||||
|
||||
@router.get('/admin/config')
|
||||
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
|
||||
'WEBUI_URL': request.app.state.config.WEBUI_URL,
|
||||
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
|
||||
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
|
||||
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
||||
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
|
||||
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
|
||||
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
|
||||
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
|
||||
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
|
||||
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
|
||||
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
|
||||
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
|
||||
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
|
||||
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
|
||||
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
|
||||
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
|
||||
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
|
||||
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
|
||||
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
|
||||
}
|
||||
return await get_config_values(ADMIN_CONFIG_KEYS)
|
||||
|
||||
|
||||
class AdminConfig(BaseModel):
|
||||
@@ -1060,81 +1083,24 @@ class AdminConfig(BaseModel):
|
||||
|
||||
@router.post('/admin/config')
|
||||
async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)):
|
||||
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
|
||||
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
|
||||
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
|
||||
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
|
||||
|
||||
request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
|
||||
request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
|
||||
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = form_data.API_KEYS_ALLOWED_ENDPOINTS
|
||||
|
||||
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
|
||||
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
|
||||
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
|
||||
)
|
||||
request.app.state.config.AUTOMATION_MAX_COUNT = (
|
||||
int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
|
||||
)
|
||||
request.app.state.config.AUTOMATION_MIN_INTERVAL = (
|
||||
updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS)
|
||||
updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
|
||||
updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
|
||||
updates['automations.min_interval'] = (
|
||||
int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else ''
|
||||
)
|
||||
request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS
|
||||
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
||||
request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR
|
||||
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
|
||||
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
|
||||
|
||||
if form_data.DEFAULT_USER_ROLE in ['pending', 'user', 'admin']:
|
||||
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
||||
|
||||
request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
|
||||
if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
|
||||
updates.pop('ui.default_user_role', None)
|
||||
|
||||
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
|
||||
|
||||
# Check if the input string matches the pattern
|
||||
if re.match(pattern, form_data.JWT_EXPIRES_IN):
|
||||
request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN
|
||||
if not re.match(pattern, form_data.JWT_EXPIRES_IN):
|
||||
updates.pop('auth.jwt_expiry', None)
|
||||
|
||||
request.app.state.config.ENABLE_COMMUNITY_SHARING = form_data.ENABLE_COMMUNITY_SHARING
|
||||
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
|
||||
|
||||
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
|
||||
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
|
||||
|
||||
request.app.state.config.PENDING_USER_OVERLAY_TITLE = form_data.PENDING_USER_OVERLAY_TITLE
|
||||
request.app.state.config.PENDING_USER_OVERLAY_CONTENT = form_data.PENDING_USER_OVERLAY_CONTENT
|
||||
|
||||
request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK
|
||||
|
||||
return {
|
||||
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
|
||||
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
|
||||
'WEBUI_URL': request.app.state.config.WEBUI_URL,
|
||||
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
|
||||
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
|
||||
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
||||
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
|
||||
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
|
||||
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
|
||||
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
||||
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
|
||||
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
|
||||
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
|
||||
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
|
||||
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
|
||||
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
|
||||
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
|
||||
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
|
||||
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
|
||||
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
|
||||
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
|
||||
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
|
||||
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
|
||||
}
|
||||
await Config.upsert(updates)
|
||||
return await get_config_values(ADMIN_CONFIG_KEYS)
|
||||
|
||||
|
||||
class LdapServerConfig(BaseModel):
|
||||
@@ -1155,21 +1121,7 @@ class LdapServerConfig(BaseModel):
|
||||
|
||||
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
|
||||
async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'label': request.app.state.config.LDAP_SERVER_LABEL,
|
||||
'host': request.app.state.config.LDAP_SERVER_HOST,
|
||||
'port': request.app.state.config.LDAP_SERVER_PORT,
|
||||
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
||||
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
||||
'app_dn': request.app.state.config.LDAP_APP_DN,
|
||||
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
|
||||
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
|
||||
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||
'use_tls': request.app.state.config.LDAP_USE_TLS,
|
||||
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
|
||||
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
|
||||
'ciphers': request.app.state.config.LDAP_CIPHERS,
|
||||
}
|
||||
return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/admin/config/ldap/server')
|
||||
@@ -1186,40 +1138,16 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
|
||||
if not value:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
|
||||
|
||||
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
|
||||
request.app.state.config.LDAP_SERVER_HOST = form_data.host
|
||||
request.app.state.config.LDAP_SERVER_PORT = form_data.port
|
||||
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
|
||||
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username
|
||||
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ''
|
||||
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ''
|
||||
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
|
||||
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
|
||||
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
|
||||
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
|
||||
request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert
|
||||
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
|
||||
|
||||
return {
|
||||
'label': request.app.state.config.LDAP_SERVER_LABEL,
|
||||
'host': request.app.state.config.LDAP_SERVER_HOST,
|
||||
'port': request.app.state.config.LDAP_SERVER_PORT,
|
||||
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
||||
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
||||
'app_dn': request.app.state.config.LDAP_APP_DN,
|
||||
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
|
||||
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
|
||||
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
|
||||
'use_tls': request.app.state.config.LDAP_USE_TLS,
|
||||
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
|
||||
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
|
||||
'ciphers': request.app.state.config.LDAP_CIPHERS,
|
||||
}
|
||||
updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
|
||||
updates['ldap.server.app_dn'] = form_data.app_dn or ''
|
||||
updates['ldap.server.app_password'] = form_data.app_dn_password or ''
|
||||
await Config.upsert(updates)
|
||||
return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.get('/admin/config/ldap')
|
||||
async def get_ldap_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
|
||||
return {'ENABLE_LDAP': await Config.get('ldap.enable')}
|
||||
|
||||
|
||||
class LdapConfigForm(BaseModel):
|
||||
@@ -1228,8 +1156,8 @@ class LdapConfigForm(BaseModel):
|
||||
|
||||
@router.post('/admin/config/ldap')
|
||||
async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)):
|
||||
request.app.state.config.ENABLE_LDAP = form_data.enable_ldap
|
||||
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
|
||||
await Config.upsert({'ldap.enable': form_data.enable_ldap})
|
||||
return {'ENABLE_LDAP': await Config.get('ldap.enable')}
|
||||
|
||||
|
||||
############################
|
||||
@@ -1237,20 +1165,163 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D
|
||||
############################
|
||||
|
||||
|
||||
# create api key
|
||||
@router.post('/api_key', response_model=ApiKey)
|
||||
async def generate_api_key(
|
||||
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_API_KEYS or (
|
||||
class OAuthConfigForm(BaseModel):
|
||||
"""All OAuth/OIDC settings exposed to the admin panel."""
|
||||
|
||||
# General OAuth
|
||||
ENABLE_OAUTH_SIGNUP: bool | None = None
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
|
||||
OAUTH_AUTO_REDIRECT: bool | None = None
|
||||
OAUTH_ALLOWED_DOMAINS: str | None = None
|
||||
OAUTH_BLOCKED_GROUPS: str | None = None
|
||||
|
||||
# Role management
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT: bool | None = None
|
||||
OAUTH_ROLES_CLAIM: str | None = None
|
||||
OAUTH_ADMIN_ROLES: str | None = None
|
||||
OAUTH_ALLOWED_ROLES: str | None = None
|
||||
|
||||
# Group management
|
||||
ENABLE_OAUTH_GROUP_MANAGEMENT: bool | None = None
|
||||
ENABLE_OAUTH_GROUP_CREATION: bool | None = None
|
||||
OAUTH_GROUP_CLAIM: str | None = None
|
||||
OAUTH_GROUP_DEFAULT_SHARE: bool | str | None = None
|
||||
|
||||
# OIDC provider settings
|
||||
OAUTH_PROVIDER_NAME: str | None = None
|
||||
OPENID_PROVIDER_URL: str | None = None
|
||||
OAUTH_CLIENT_ID: str | None = None
|
||||
OAUTH_CLIENT_SECRET: str | None = None
|
||||
OPENID_REDIRECT_URI: str | None = None
|
||||
OAUTH_SCOPES: str | None = None
|
||||
OAUTH_CODE_CHALLENGE_METHOD: str | None = None
|
||||
OAUTH_TOKEN_ENDPOINT_AUTH_METHOD: str | None = None
|
||||
OPENID_END_SESSION_ENDPOINT: str | None = None
|
||||
OAUTH_TIMEOUT: int | str | None = None
|
||||
OAUTH_CLIENT_TIMEOUT: int | str | None = None
|
||||
|
||||
# Claims
|
||||
OAUTH_EMAIL_CLAIM: str | None = None
|
||||
OAUTH_USERNAME_CLAIM: str | None = None
|
||||
OAUTH_PICTURE_CLAIM: str | None = None
|
||||
OAUTH_SUB_CLAIM: str | None = None
|
||||
OAUTH_AUDIENCE: str | None = None
|
||||
|
||||
# Profile update toggles
|
||||
OAUTH_UPDATE_EMAIL_ON_LOGIN: bool | None = None
|
||||
OAUTH_UPDATE_NAME_ON_LOGIN: bool | None = None
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN: bool | None = None
|
||||
|
||||
# Token
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE: bool | None = None
|
||||
|
||||
|
||||
OAUTH_COMMA_LIST_FIELDS = {
|
||||
'OAUTH_ALLOWED_DOMAINS',
|
||||
'OAUTH_ADMIN_ROLES',
|
||||
'OAUTH_ALLOWED_ROLES',
|
||||
}
|
||||
|
||||
|
||||
OAUTH_CONFIG_KEYS = {
|
||||
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
|
||||
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
|
||||
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
|
||||
'OAUTH_ALLOWED_DOMAINS': 'oauth.allowed_domains',
|
||||
'OAUTH_BLOCKED_GROUPS': 'oauth.blocked_groups',
|
||||
'ENABLE_OAUTH_ROLE_MANAGEMENT': 'oauth.enable_role_mapping',
|
||||
'OAUTH_ROLES_CLAIM': 'oauth.roles_claim',
|
||||
'OAUTH_ADMIN_ROLES': 'oauth.admin_roles',
|
||||
'OAUTH_ALLOWED_ROLES': 'oauth.allowed_roles',
|
||||
'ENABLE_OAUTH_GROUP_MANAGEMENT': 'oauth.enable_group_mapping',
|
||||
'ENABLE_OAUTH_GROUP_CREATION': 'oauth.enable_group_creation',
|
||||
'OAUTH_GROUP_CLAIM': 'oauth.group_claim',
|
||||
'OAUTH_GROUP_DEFAULT_SHARE': 'oauth.group_default_share',
|
||||
'OAUTH_PROVIDER_NAME': 'oauth.provider_name',
|
||||
'OPENID_PROVIDER_URL': 'oauth.provider_url',
|
||||
'OAUTH_CLIENT_ID': 'oauth.client_id',
|
||||
'OAUTH_CLIENT_SECRET': 'oauth.client_secret',
|
||||
'OPENID_REDIRECT_URI': 'oauth.redirect_uri',
|
||||
'OAUTH_SCOPES': 'oauth.scopes',
|
||||
'OAUTH_CODE_CHALLENGE_METHOD': 'oauth.code_challenge_method',
|
||||
'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD': 'oauth.token_endpoint_auth_method',
|
||||
'OPENID_END_SESSION_ENDPOINT': 'oauth.end_session_endpoint',
|
||||
'OAUTH_TIMEOUT': 'oauth.timeout',
|
||||
'OAUTH_CLIENT_TIMEOUT': 'oauth.client.timeout',
|
||||
'OAUTH_EMAIL_CLAIM': 'oauth.email_claim',
|
||||
'OAUTH_USERNAME_CLAIM': 'oauth.username_claim',
|
||||
'OAUTH_PICTURE_CLAIM': 'oauth.picture_claim',
|
||||
'OAUTH_SUB_CLAIM': 'oauth.sub_claim',
|
||||
'OAUTH_AUDIENCE': 'oauth.audience',
|
||||
'OAUTH_UPDATE_EMAIL_ON_LOGIN': 'oauth.update_email_on_login',
|
||||
'OAUTH_UPDATE_NAME_ON_LOGIN': 'oauth.update_name_on_login',
|
||||
'OAUTH_UPDATE_PICTURE_ON_LOGIN': 'oauth.update_picture_on_login',
|
||||
'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': 'oauth.refresh_token.include_scope',
|
||||
}
|
||||
|
||||
|
||||
def _format_oauth_form_value(field: str, value):
|
||||
if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, list):
|
||||
return ','.join(str(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_oauth_update_value(field: str, value):
|
||||
if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, str):
|
||||
return [item.strip() for item in value.split(',') if item.strip()]
|
||||
if field in {'OAUTH_TIMEOUT', 'OAUTH_CLIENT_TIMEOUT'} and value == '':
|
||||
return ''
|
||||
return value
|
||||
|
||||
|
||||
async def get_oauth_config_values() -> dict:
|
||||
values = await Config.get_many(*OAUTH_CONFIG_KEYS.values())
|
||||
return {
|
||||
field: _format_oauth_form_value(field, values[storage_key])
|
||||
for field, storage_key in OAUTH_CONFIG_KEYS.items()
|
||||
if storage_key in values
|
||||
}
|
||||
|
||||
|
||||
def oauth_config_updates(data: dict) -> dict:
|
||||
return {
|
||||
OAUTH_CONFIG_KEYS[field]: _parse_oauth_update_value(field, value)
|
||||
for field, value in data.items()
|
||||
if field in OAUTH_CONFIG_KEYS
|
||||
}
|
||||
|
||||
|
||||
@router.get('/admin/config/oauth', response_model=OAuthConfigForm)
|
||||
async def get_oauth_config(request: Request, user=Depends(get_admin_user)):
|
||||
return await get_oauth_config_values()
|
||||
|
||||
|
||||
@router.post('/admin/config/oauth', response_model=OAuthConfigForm)
|
||||
async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)):
|
||||
await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True)))
|
||||
return await get_oauth_config_values()
|
||||
|
||||
|
||||
async def _check_api_key_permission(request: Request, user, db: AsyncSession):
|
||||
if not await Config.get('auth.enable_api_keys') or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS)
|
||||
and not await has_permission(
|
||||
user.id, 'features.api_keys', await Config.get('user.permissions'), db=db
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
# create api key
|
||||
@router.post('/api_key', response_model=ApiKey)
|
||||
async def generate_api_key(
|
||||
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await _check_api_key_permission(request, user, db)
|
||||
|
||||
api_key = create_api_key()
|
||||
success = await Users.update_user_api_key_by_id(user.id, api_key, db=db)
|
||||
|
||||
@@ -1264,13 +1335,19 @@ async def generate_api_key(
|
||||
|
||||
# delete api key
|
||||
@router.delete('/api_key', response_model=bool)
|
||||
async def delete_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
|
||||
async def delete_api_key(
|
||||
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await _check_api_key_permission(request, user, db)
|
||||
return await Users.delete_user_api_key_by_id(user.id, db=db)
|
||||
|
||||
|
||||
# get api key
|
||||
@router.get('/api_key', response_model=ApiKey)
|
||||
async def get_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
|
||||
async def get_api_key(
|
||||
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await _check_api_key_permission(request, user, db)
|
||||
api_key = await Users.get_user_api_key_by_id(user.id, db=db)
|
||||
if api_key:
|
||||
return {
|
||||
@@ -1342,11 +1419,11 @@ async def token_exchange(
|
||||
)
|
||||
|
||||
# Extract user information from the token claims
|
||||
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
|
||||
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
|
||||
email_claim = await Config.get('oauth.email_claim', 'email')
|
||||
|
||||
# Get sub claim
|
||||
sub = user_data.get(request.app.state.config.OAUTH_SUB_CLAIM or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
|
||||
sub_claim = await Config.get('oauth.sub_claim')
|
||||
sub = user_data.get(sub_claim or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
|
||||
if not sub:
|
||||
log.warning(f'Token exchange failed: sub claim missing from user data')
|
||||
raise HTTPException(
|
||||
@@ -1364,10 +1441,10 @@ async def token_exchange(
|
||||
email = email.lower()
|
||||
|
||||
# Enforce domain allowlist — same check as the normal OAuth callback
|
||||
if (
|
||||
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
):
|
||||
oauth_allowed_domains = await Config.get('oauth.allowed_domains', [])
|
||||
if isinstance(oauth_allowed_domains, str):
|
||||
oauth_allowed_domains = [domain.strip() for domain in oauth_allowed_domains.split(',') if domain.strip()]
|
||||
if '*' not in oauth_allowed_domains and email.split('@')[-1] not in oauth_allowed_domains:
|
||||
log.warning(f'Token exchange denied: email domain not in allowed domains list')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -1377,7 +1454,7 @@ async def token_exchange(
|
||||
# Try to find the user by OAuth sub
|
||||
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
|
||||
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
|
||||
if not user and await Config.get('oauth.merge_accounts_by_email'):
|
||||
# Try to find by email if merge is enabled
|
||||
user = await Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
|
||||
@@ -14,6 +14,7 @@ from open_webui.models.automations import (
|
||||
AutomationRuns,
|
||||
Automations,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.automations import (
|
||||
@@ -38,13 +39,14 @@ PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
async def check_automations_permission(request, user):
|
||||
if not request.app.state.config.ENABLE_AUTOMATIONS:
|
||||
config = await Config.get_many('automations.enable', 'user.permissions')
|
||||
if not config.get('automations.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'features.automations', config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -72,7 +74,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
||||
|
||||
# Max count (create only)
|
||||
if is_create:
|
||||
max_count = request.app.state.config.AUTOMATION_MAX_COUNT
|
||||
max_count = await Config.get('automations.max_count')
|
||||
if max_count:
|
||||
max_count = int(max_count)
|
||||
if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count:
|
||||
@@ -82,7 +84,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
||||
)
|
||||
|
||||
# Min interval (create + update)
|
||||
min_interval = request.app.state.config.AUTOMATION_MIN_INTERVAL
|
||||
min_interval = await Config.get('automations.min_interval')
|
||||
if min_interval:
|
||||
min_interval = int(min_interval)
|
||||
if min_interval > 0:
|
||||
|
||||
@@ -19,6 +19,7 @@ from open_webui.models.calendar import (
|
||||
CalendarUpdateForm,
|
||||
RSVPForm,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
@@ -34,13 +35,14 @@ SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__'
|
||||
|
||||
async def check_calendar_permission(request: Request, user):
|
||||
"""Check global feature flag AND per-user permission for calendar access."""
|
||||
if not request.app.state.config.ENABLE_CALENDAR:
|
||||
config = await Config.get_many('calendar.enable', 'user.permissions')
|
||||
if not config.get('calendar.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'features.calendar', config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -50,11 +52,12 @@ async def check_calendar_permission(request: Request, user):
|
||||
|
||||
async def _user_has_automations(request: Request, user) -> bool:
|
||||
"""Check if automations feature is available to this user."""
|
||||
if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False):
|
||||
config = await Config.get_many('automations.enable', 'user.permissions')
|
||||
if not config.get('automations.enable', False):
|
||||
return False
|
||||
if user.role == 'admin':
|
||||
return True
|
||||
return await has_permission(user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS)
|
||||
return await has_permission(user.id, 'features.automations', config.get('user.permissions'))
|
||||
|
||||
|
||||
async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel:
|
||||
@@ -116,7 +119,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM
|
||||
# could create a calendar with `principal_id='*' permission='read'|'write'`,
|
||||
# making their events readable or writable by any other verified user.
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -373,7 +376,7 @@ async def update_calendar(
|
||||
# publicly readable/writable without the corresponding sharing permission.
|
||||
if form_data.access_grants is not None:
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
@@ -11,6 +11,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import STATIC_DIR
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.channels import (
|
||||
ChannelForm,
|
||||
ChannelModel,
|
||||
@@ -31,9 +32,7 @@ from open_webui.models.messages import (
|
||||
from open_webui.models.users import (
|
||||
UserIdNameResponse,
|
||||
UserIdNameStatusResponse,
|
||||
UserListResponse,
|
||||
UserModel,
|
||||
UserModelResponse,
|
||||
UserNameResponse,
|
||||
Users,
|
||||
)
|
||||
@@ -125,7 +124,7 @@ def get_channel_permitted_group_and_user_ids(
|
||||
|
||||
async def check_channels_access(request: Request, user: Optional[UserModel] = None):
|
||||
"""Dependency to ensure channels are globally enabled."""
|
||||
if not request.app.state.config.ENABLE_CHANNELS:
|
||||
if not await Config.get('channels.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'),
|
||||
@@ -133,7 +132,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No
|
||||
|
||||
if user:
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.channels', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'features.channels', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -294,7 +293,7 @@ async def create_new_channel(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -440,7 +439,40 @@ async def get_channel_by_id(
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
@router.get('/{id}/members', response_model=UserListResponse)
|
||||
class ChannelMemberResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
profile_image_url: str | None = None
|
||||
presence_state: str | None = None
|
||||
status_emoji: str | None = None
|
||||
status_message: str | None = None
|
||||
status_expires_at: int | None = None
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class ChannelMemberListResponse(BaseModel):
|
||||
users: list[ChannelMemberResponse]
|
||||
total: int
|
||||
|
||||
|
||||
def serialize_channel_member(user: UserModel) -> ChannelMemberResponse:
|
||||
return ChannelMemberResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
profile_image_url=user.profile_image_url,
|
||||
presence_state=user.presence_state,
|
||||
status_emoji=user.status_emoji,
|
||||
status_message=user.status_message,
|
||||
status_expires_at=user.status_expires_at,
|
||||
is_active=Users.is_active(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{id}/members', response_model=ChannelMemberListResponse)
|
||||
async def get_channel_members_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
@@ -475,7 +507,7 @@ async def get_channel_members_by_id(
|
||||
total = len(fetched_users)
|
||||
|
||||
return {
|
||||
'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users],
|
||||
'users': [serialize_channel_member(u) for u in fetched_users],
|
||||
'total': total,
|
||||
}
|
||||
else:
|
||||
@@ -503,7 +535,7 @@ async def get_channel_members_by_id(
|
||||
total = result['total']
|
||||
|
||||
return {
|
||||
'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users],
|
||||
'users': [serialize_channel_member(u) for u in fetched_users],
|
||||
'total': total,
|
||||
}
|
||||
|
||||
@@ -632,7 +664,7 @@ async def update_channel_by_id(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -826,8 +858,8 @@ async def get_pinned_channel_messages(
|
||||
|
||||
async def send_notification(request, channel, message, active_user_ids, db=None):
|
||||
name = request.app.state.WEBUI_NAME
|
||||
webui_url = request.app.state.config.WEBUI_URL
|
||||
enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS
|
||||
webui_url = await Config.get('webui.url')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
users = await get_channel_users_with_access(channel, 'read', db=db)
|
||||
|
||||
@@ -978,7 +1010,7 @@ async def model_response_handler(request, channel, message, user, db=None):
|
||||
)
|
||||
|
||||
tool_ids = _resolve_model_tool_ids(request.app, model_id)
|
||||
features = _resolve_model_features(request.app, model_id)
|
||||
features = await _resolve_model_features(request.app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(request.app, model_id)
|
||||
|
||||
# Build full form_data — same shape as frontend POST.
|
||||
|
||||
@@ -12,6 +12,7 @@ from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import (
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
@@ -32,6 +33,7 @@ from open_webui.models.tags import TagModel, Tags
|
||||
from open_webui.socket.main import get_event_emitter
|
||||
from open_webui.tasks import stop_item_tasks
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.middleware import serialize_output
|
||||
from open_webui.utils.misc import get_message_list
|
||||
@@ -42,6 +44,17 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def require_chat_import_permission(request: Request, user, db: AsyncSession):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'chat.import', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetChatList
|
||||
# Let the record outlive the session, so that what was
|
||||
@@ -400,7 +413,7 @@ async def export_chat_stats(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
# Check if the user has permission to share/export chats
|
||||
if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING):
|
||||
if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -449,7 +462,7 @@ async def export_single_chat_stats(
|
||||
Returns ChatStatsExport for the specified chat.
|
||||
"""
|
||||
# Check if the user has permission to share/export chats
|
||||
if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING):
|
||||
if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -496,7 +509,7 @@ async def delete_all_user_chats(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role == 'user' and not await has_permission(
|
||||
user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'chat.delete', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -561,10 +574,13 @@ async def create_new_chat(
|
||||
# to assume the column is clean. Also catches non-UUID / nonexistent IDs.
|
||||
if form_data.folder_id is not None:
|
||||
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
# Check shared folder write access
|
||||
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
|
||||
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
|
||||
@@ -581,10 +597,13 @@ async def create_new_chat(
|
||||
|
||||
@router.post('/import', response_model=list[ChatResponse])
|
||||
async def import_chats(
|
||||
request: Request,
|
||||
form_data: ChatsImportForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
try:
|
||||
chats = await Chats.import_chats(user.id, form_data.chats, db=db)
|
||||
return chats
|
||||
@@ -800,6 +819,19 @@ async def get_archived_session_user_chat_list(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetArchivedChatsCount
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/archived/count', response_model=int)
|
||||
async def get_archived_session_user_chat_count(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await Chats.count_archived_chats_by_user_id(user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# ArchiveAllChats
|
||||
############################
|
||||
@@ -821,10 +853,27 @@ async def unarchive_all_chats(user=Depends(get_verified_user), db: AsyncSession
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChats
|
||||
# UnshareAllChats
|
||||
############################
|
||||
|
||||
|
||||
@router.delete('/share/all', response_model=bool)
|
||||
async def unshare_all_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
# Collect chat_ids that have shares so we can clear share_id and access grants
|
||||
shared_list = await SharedChats.get_by_user_id(user.id, db=db)
|
||||
chat_ids = [s.chat_id for s in shared_list]
|
||||
|
||||
# Delete all shared_chat rows for this user
|
||||
result = await SharedChats.delete_all_by_user_id(user.id, db=db)
|
||||
|
||||
# Clear share_id on the original chats and remove access grants
|
||||
for chat_id in chat_ids:
|
||||
await Chats.update_chat_share_id_by_id(chat_id, None, db=db)
|
||||
await AccessGrants.set_access_grants('shared_chat', chat_id, [], db=db)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/shared', response_model=list[SharedChatResponse])
|
||||
async def get_shared_session_user_chat_list(
|
||||
page: int | None = None,
|
||||
@@ -951,6 +1000,14 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
@@ -1138,7 +1195,7 @@ async def delete_chat_by_id(
|
||||
|
||||
return result
|
||||
else:
|
||||
if not await has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS):
|
||||
if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -1198,11 +1255,14 @@ class CloneForm(BaseModel):
|
||||
|
||||
@router.post('/{id}/clone', response_model=ChatResponse | None)
|
||||
async def clone_chat_by_id(
|
||||
request: Request,
|
||||
form_data: CloneForm,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
updated_chat = {
|
||||
@@ -1246,8 +1306,13 @@ async def clone_chat_by_id(
|
||||
|
||||
@router.post('/{id}/clone/shared', response_model=ChatResponse | None)
|
||||
async def clone_shared_chat_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
chat = await Chats.get_chat_by_share_id(id, db=db)
|
||||
|
||||
# Fallback: admins can also access any chat directly by chat ID
|
||||
@@ -1350,7 +1415,7 @@ async def share_chat_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'chat.share', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
|
||||
@@ -1426,7 +1491,7 @@ async def update_shared_chat_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -1493,10 +1558,13 @@ async def update_chat_folder_id_by_id(
|
||||
# folder_id values. None is allowed (moves the chat out of any folder).
|
||||
if form_data.folder_id is not None:
|
||||
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
# Check shared folder write access
|
||||
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
|
||||
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Optional
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
from open_webui.config import BannerModel, async_save_config, get_config, save_config
|
||||
from open_webui.config import BannerModel
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
@@ -34,6 +35,44 @@ router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONNECTIONS_CONFIG_KEYS = {
|
||||
'ENABLE_DIRECT_CONNECTIONS': 'direct.enable',
|
||||
'ENABLE_BASE_MODELS_CACHE': 'models.base_models_cache',
|
||||
}
|
||||
CODE_EXECUTION_CONFIG_KEYS = {
|
||||
'ENABLE_CODE_EXECUTION': 'code_execution.enable',
|
||||
'CODE_EXECUTION_ENGINE': 'code_execution.engine',
|
||||
'CODE_EXECUTION_JUPYTER_URL': 'code_execution.jupyter.url',
|
||||
'CODE_EXECUTION_JUPYTER_AUTH': 'code_execution.jupyter.auth',
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': 'code_execution.jupyter.auth_token',
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': 'code_execution.jupyter.auth_password',
|
||||
'CODE_EXECUTION_JUPYTER_TIMEOUT': 'code_execution.jupyter.timeout',
|
||||
'ENABLE_CODE_INTERPRETER': 'code_interpreter.enable',
|
||||
'CODE_INTERPRETER_ENGINE': 'code_interpreter.engine',
|
||||
'CODE_INTERPRETER_PROMPT_TEMPLATE': 'code_interpreter.prompt_template',
|
||||
'CODE_INTERPRETER_JUPYTER_URL': 'code_interpreter.jupyter.url',
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH': 'code_interpreter.jupyter.auth',
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': 'code_interpreter.jupyter.auth_token',
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': 'code_interpreter.jupyter.auth_password',
|
||||
'CODE_INTERPRETER_JUPYTER_TIMEOUT': 'code_interpreter.jupyter.timeout',
|
||||
}
|
||||
MODELS_CONFIG_KEYS = {
|
||||
'DEFAULT_MODELS': 'ui.default_models',
|
||||
'DEFAULT_PINNED_MODELS': 'ui.default_pinned_models',
|
||||
'MODEL_ORDER_LIST': 'ui.model_order_list',
|
||||
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
|
||||
'DEFAULT_MODEL_PARAMS': 'models.default_params',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
return {key_map[field]: value for field, value in data.items() if field in key_map}
|
||||
|
||||
|
||||
############################
|
||||
# ImportConfig
|
||||
@@ -48,9 +87,8 @@ class ImportConfigForm(BaseModel):
|
||||
|
||||
@router.post('/import', response_model=dict)
|
||||
async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)):
|
||||
await async_save_config(form_data.config)
|
||||
request.app.state.config._sync_to_redis()
|
||||
return get_config()
|
||||
await Config.upsert(form_data.config)
|
||||
return await Config.get_all()
|
||||
|
||||
|
||||
############################
|
||||
@@ -60,7 +98,12 @@ async def import_config(request: Request, form_data: ImportConfigForm, user=Depe
|
||||
|
||||
@router.get('/export', response_model=dict)
|
||||
async def export_config(user=Depends(get_admin_user)):
|
||||
return get_config()
|
||||
return await Config.get_all()
|
||||
|
||||
|
||||
@router.get('/namespace/{namespace}', response_model=dict)
|
||||
async def get_config_namespace(namespace: str, user=Depends(get_admin_user)):
|
||||
return await Config.get_namespace(namespace)
|
||||
|
||||
|
||||
############################
|
||||
@@ -75,10 +118,7 @@ class ConnectionsConfigForm(BaseModel):
|
||||
|
||||
@router.get('/connections', response_model=ConnectionsConfigForm)
|
||||
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||
}
|
||||
return await get_config_values(CONNECTIONS_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/connections', response_model=ConnectionsConfigForm)
|
||||
@@ -87,13 +127,8 @@ async def set_connections_config(
|
||||
form_data: ConnectionsConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = form_data.ENABLE_DIRECT_CONNECTIONS
|
||||
request.app.state.config.ENABLE_BASE_MODELS_CACHE = form_data.ENABLE_BASE_MODELS_CACHE
|
||||
|
||||
return {
|
||||
'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE,
|
||||
}
|
||||
await Config.upsert(config_updates(form_data.model_dump(), CONNECTIONS_CONFIG_KEYS))
|
||||
return await get_config_values(CONNECTIONS_CONFIG_KEYS)
|
||||
|
||||
|
||||
class OAuthClientRegistrationForm(BaseModel):
|
||||
@@ -167,9 +202,7 @@ class ToolServersConfigForm(BaseModel):
|
||||
|
||||
@router.get('/tool_servers', response_model=ToolServersConfigForm)
|
||||
async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
return {'TOOL_SERVER_CONNECTIONS': await Config.get('tool_server.connections')}
|
||||
|
||||
|
||||
@router.post('/tool_servers', response_model=ToolServersConfigForm)
|
||||
@@ -178,7 +211,8 @@ async def set_tool_servers_config(
|
||||
form_data: ToolServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
existing_connections = await Config.get('tool_server.connections', []) or []
|
||||
for connection in existing_connections:
|
||||
server_type = connection.get('type', 'openapi')
|
||||
auth_type = connection.get('auth_type', 'none')
|
||||
|
||||
@@ -193,13 +227,12 @@ async def set_tool_servers_config(
|
||||
pass
|
||||
|
||||
# Set new tool server connections
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
connections = [connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS]
|
||||
await Config.upsert({'tool_server.connections': connections})
|
||||
|
||||
await set_tool_servers(request)
|
||||
|
||||
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
for connection in connections:
|
||||
server_type = connection.get('type', 'openapi')
|
||||
if server_type == 'mcp':
|
||||
server_id = connection.get('info', {}).get('id')
|
||||
@@ -216,9 +249,7 @@ async def set_tool_servers_config(
|
||||
log.debug(f'Failed to add OAuth client for MCP tool server: {e}')
|
||||
continue
|
||||
|
||||
return {
|
||||
'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
return {'TOOL_SERVER_CONNECTIONS': connections}
|
||||
|
||||
|
||||
class TerminalServerConnection(BaseModel):
|
||||
@@ -249,9 +280,7 @@ class TerminalServersConfigForm(BaseModel):
|
||||
|
||||
@router.get('/terminal_servers')
|
||||
async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
|
||||
}
|
||||
return {'TERMINAL_SERVER_CONNECTIONS': await Config.get('terminal_server.connections')}
|
||||
|
||||
|
||||
@router.post('/terminal_servers')
|
||||
@@ -260,15 +289,12 @@ async def set_terminal_servers_config(
|
||||
form_data: TerminalServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS
|
||||
]
|
||||
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
|
||||
await Config.upsert({'terminal_server.connections': connections})
|
||||
|
||||
await set_terminal_servers(request)
|
||||
|
||||
return {
|
||||
'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
|
||||
}
|
||||
return {'TERMINAL_SERVER_CONNECTIONS': connections}
|
||||
|
||||
|
||||
@router.post('/terminal_servers/verify')
|
||||
@@ -518,67 +544,15 @@ class CodeInterpreterConfigForm(BaseModel):
|
||||
|
||||
@router.get('/code_execution', response_model=CodeInterpreterConfigForm)
|
||||
async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION,
|
||||
'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE,
|
||||
'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
|
||||
'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
|
||||
'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER,
|
||||
'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE,
|
||||
'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
|
||||
'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
|
||||
'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
|
||||
}
|
||||
return await get_config_values(CODE_EXECUTION_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/code_execution', response_model=CodeInterpreterConfigForm)
|
||||
async def set_code_execution_config(
|
||||
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
|
||||
):
|
||||
request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION
|
||||
|
||||
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = form_data.CODE_EXECUTION_JUPYTER_URL
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = form_data.CODE_EXECUTION_JUPYTER_AUTH
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = form_data.CODE_EXECUTION_JUPYTER_TIMEOUT
|
||||
|
||||
request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER
|
||||
request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE
|
||||
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = form_data.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = form_data.CODE_INTERPRETER_JUPYTER_URL
|
||||
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = form_data.CODE_INTERPRETER_JUPYTER_AUTH
|
||||
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT
|
||||
|
||||
return {
|
||||
'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION,
|
||||
'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE,
|
||||
'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
|
||||
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
|
||||
'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
|
||||
'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER,
|
||||
'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE,
|
||||
'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
|
||||
'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
|
||||
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
|
||||
'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
|
||||
}
|
||||
await Config.upsert(config_updates(form_data.model_dump(), CODE_EXECUTION_CONFIG_KEYS))
|
||||
return await get_config_values(CODE_EXECUTION_CONFIG_KEYS)
|
||||
|
||||
|
||||
############################
|
||||
@@ -595,35 +569,19 @@ class ModelsConfigForm(BaseModel):
|
||||
@router.get('/models/defaults')
|
||||
async def get_models_defaults(request: Request, user=Depends(get_verified_user)):
|
||||
return {
|
||||
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
'DEFAULT_MODEL_METADATA': await Config.get('models.default_metadata'),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/models', response_model=ModelsConfigForm)
|
||||
async def get_models_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS,
|
||||
'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS,
|
||||
'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST,
|
||||
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS,
|
||||
}
|
||||
return await get_config_values(MODELS_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/models', response_model=ModelsConfigForm)
|
||||
async def set_models_config(request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)):
|
||||
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
|
||||
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
|
||||
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
|
||||
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
|
||||
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
|
||||
return {
|
||||
'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS,
|
||||
'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS,
|
||||
'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST,
|
||||
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS,
|
||||
}
|
||||
await Config.upsert(config_updates(form_data.model_dump(), MODELS_CONFIG_KEYS))
|
||||
return await get_config_values(MODELS_CONFIG_KEYS)
|
||||
|
||||
|
||||
class PromptSuggestion(BaseModel):
|
||||
@@ -642,8 +600,8 @@ async def set_default_suggestions(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
data = form_data.model_dump()
|
||||
request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data['suggestions']
|
||||
return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS
|
||||
await Config.upsert({'ui.prompt_suggestions': data['suggestions']})
|
||||
return await Config.get('ui.prompt_suggestions')
|
||||
|
||||
|
||||
############################
|
||||
@@ -662,8 +620,8 @@ async def set_banners(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
data = form_data.model_dump()
|
||||
request.app.state.config.BANNERS = data['banners']
|
||||
return request.app.state.config.BANNERS
|
||||
await Config.upsert({'ui.banners': data['banners']})
|
||||
return await Config.get('ui.banners')
|
||||
|
||||
|
||||
@router.get('/banners', response_model=list[BannerModel])
|
||||
@@ -671,4 +629,4 @@ async def get_banners(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return request.app.state.config.BANNERS
|
||||
return await Config.get('ui.banners')
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.feedbacks import (
|
||||
FeedbackForm,
|
||||
FeedbackIdResponse,
|
||||
@@ -25,6 +26,16 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
EVALUATION_CONFIG_KEYS = {
|
||||
'ENABLE_EVALUATION_ARENA_MODELS': 'evaluation.arena.enable',
|
||||
'EVALUATION_ARENA_MODELS': 'evaluation.arena.models',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
# Leaderboard Elo Rating Computation
|
||||
# The judgment has already been rendered with grace;
|
||||
@@ -255,10 +266,7 @@ async def get_model_history(
|
||||
|
||||
@router.get('/config')
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'ENABLE_EVALUATION_ARENA_MODELS': request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS,
|
||||
'EVALUATION_ARENA_MODELS': request.app.state.config.EVALUATION_ARENA_MODELS,
|
||||
}
|
||||
return await get_config_values(EVALUATION_CONFIG_KEYS)
|
||||
|
||||
|
||||
############################
|
||||
@@ -277,15 +285,13 @@ async def update_config(
|
||||
form_data: UpdateConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
config = request.app.state.config
|
||||
updates = {}
|
||||
if form_data.ENABLE_EVALUATION_ARENA_MODELS is not None:
|
||||
config.ENABLE_EVALUATION_ARENA_MODELS = form_data.ENABLE_EVALUATION_ARENA_MODELS
|
||||
updates['evaluation.arena.enable'] = form_data.ENABLE_EVALUATION_ARENA_MODELS
|
||||
if form_data.EVALUATION_ARENA_MODELS is not None:
|
||||
config.EVALUATION_ARENA_MODELS = form_data.EVALUATION_ARENA_MODELS
|
||||
return {
|
||||
'ENABLE_EVALUATION_ARENA_MODELS': config.ENABLE_EVALUATION_ARENA_MODELS,
|
||||
'EVALUATION_ARENA_MODELS': config.EVALUATION_ARENA_MODELS,
|
||||
}
|
||||
updates['evaluation.arena.models'] = form_data.EVALUATION_ARENA_MODELS
|
||||
await Config.upsert(updates)
|
||||
return await get_config_values(EVALUATION_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.get('/feedbacks/models', response_model=list[str])
|
||||
|
||||
@@ -26,6 +26,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_db_context, get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import (
|
||||
FileForm,
|
||||
@@ -123,7 +124,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 = await Config.get('audio.stt.supported_content_types', [])
|
||||
|
||||
if content_type and strict_match_mime_type(stt_supported, content_type):
|
||||
# Audio / STT-supported files → transcribe then index
|
||||
@@ -144,7 +145,7 @@ async def process_uploaded_file(
|
||||
elif (
|
||||
content_type
|
||||
and content_type.startswith(('image/', 'video/'))
|
||||
and request.app.state.config.CONTENT_EXTRACTION_ENGINE != 'external'
|
||||
and await Config.get('rag.content_extraction_engine') != 'external'
|
||||
):
|
||||
# Media files without an external extraction engine
|
||||
if content_type.startswith('video/'):
|
||||
@@ -178,19 +179,39 @@ async def process_uploaded_file(
|
||||
knowledge_id = file_metadata.get('knowledge_id')
|
||||
if knowledge_id:
|
||||
try:
|
||||
await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
# Gate like POST /knowledge/{id}/file/add: a client-supplied
|
||||
# metadata.knowledge_id must not let a non-writer attach files (CWE-862/863).
|
||||
knowledge = await Knowledges.get_knowledge_by_id(id=knowledge_id, db=db_session)
|
||||
can_write = bool(knowledge) and (
|
||||
knowledge.user_id == user.id
|
||||
or user.role == 'admin'
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge.id,
|
||||
permission='write',
|
||||
db=db_session,
|
||||
)
|
||||
)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
|
||||
if not can_write:
|
||||
log.warning(
|
||||
f'Refusing to auto-link file {file_item.id} to knowledge '
|
||||
f'{knowledge_id}: user {user.id} lacks write access'
|
||||
)
|
||||
else:
|
||||
await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
|
||||
|
||||
@@ -268,12 +289,11 @@ async def upload_file_handler(
|
||||
# Remove the leading dot from the file extension and lowercase it
|
||||
file_extension = file_extension[1:].lower() if file_extension else ''
|
||||
|
||||
if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
|
||||
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
|
||||
]
|
||||
allowed_file_extensions = await Config.get('rag.file.allowed_extensions')
|
||||
if process and allowed_file_extensions:
|
||||
allowed_file_extensions = [ext for ext in allowed_file_extensions if ext]
|
||||
|
||||
if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS:
|
||||
if file_extension not in allowed_file_extensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(f'File type {file_extension} is not allowed'),
|
||||
@@ -443,6 +463,20 @@ async def search_files(
|
||||
return files
|
||||
|
||||
|
||||
############################
|
||||
# Count Files
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/count', response_model=int)
|
||||
async def count_files(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
|
||||
return await Files.count_files_by_user_id(user_id=user_id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# Delete All Files
|
||||
############################
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import (
|
||||
FolderForm,
|
||||
@@ -19,7 +20,13 @@ from open_webui.models.folders import (
|
||||
Folders,
|
||||
FolderUpdateForm,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import (
|
||||
filter_allowed_access_grants,
|
||||
)
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from pydantic import BaseModel
|
||||
@@ -31,6 +38,29 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
|
||||
|
||||
|
||||
async def check_folders_permission(request: Request, user, db=None):
|
||||
"""Verify the folders feature is enabled and the user has permission."""
|
||||
config = await Config.get_many('folders.enable', 'user.permissions')
|
||||
if config.get('folders.enable') is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'features.folders',
|
||||
config.get('user.permissions'),
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# Get Folders
|
||||
############################
|
||||
@@ -42,22 +72,7 @@ async def get_folders(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if request.app.state.config.ENABLE_FOLDERS is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'features.folders',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_folders_permission(request, user, db=db)
|
||||
|
||||
folders = await Folders.get_folders_by_user_id(user.id, db=db)
|
||||
|
||||
@@ -87,10 +102,12 @@ async def get_folders(
|
||||
|
||||
@router.post('/')
|
||||
async def create_folder(
|
||||
request: Request,
|
||||
form_data: FolderForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
form_data.parent_id, user.id, form_data.name, db=db
|
||||
)
|
||||
@@ -101,6 +118,27 @@ async def create_folder(
|
||||
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
|
||||
)
|
||||
|
||||
# Check if creating a subfolder in a shared folder
|
||||
if form_data.parent_id:
|
||||
parent = await Folders.get_folder_by_id(form_data.parent_id, db=db)
|
||||
if parent and parent.user_id != user.id:
|
||||
# Creating subfolder in someone else's shared folder
|
||||
if user.role != 'admin' and not await _has_folder_access(user.id, parent, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
# Create as the folder owner's subfolder (keep tree consistent)
|
||||
try:
|
||||
folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db)
|
||||
return folder
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error creating folder'),
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db)
|
||||
return folder
|
||||
@@ -113,21 +151,88 @@ async def create_folder(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# Get Shared Folders
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/shared')
|
||||
async def get_shared_folders(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get all folders shared with the current user (not owned by them)."""
|
||||
await check_folders_permission(request, user, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
group_ids = {g.id for g in groups}
|
||||
|
||||
folder_perms = await Folders.get_shared_folder_ids_for_user(
|
||||
user.id, group_ids, db=db
|
||||
)
|
||||
|
||||
# Filter out folders owned by the user
|
||||
results = []
|
||||
owner_cache = {}
|
||||
for folder_id, permission in folder_perms.items():
|
||||
folder = await Folders.get_folder_by_id(folder_id, db=db)
|
||||
if not folder or folder.user_id == user.id:
|
||||
continue
|
||||
|
||||
# Get owner name (cached)
|
||||
if folder.user_id not in owner_cache:
|
||||
owner = await Users.get_user_by_id(folder.user_id, db=db)
|
||||
owner_cache[folder.user_id] = owner.name if owner else 'Unknown'
|
||||
|
||||
results.append({
|
||||
**folder.model_dump(),
|
||||
'owner_name': owner_cache[folder.user_id],
|
||||
'permission': permission,
|
||||
})
|
||||
|
||||
# Also include child folders of shared folders (inheritance)
|
||||
shared_root_ids = {r['id'] for r in results}
|
||||
for root_id in list(shared_root_ids):
|
||||
root_folder = await Folders.get_folder_by_id(root_id, db=db)
|
||||
if root_folder:
|
||||
children = await Folders.get_children_folders_by_id_and_user_id(
|
||||
root_id, root_folder.user_id, db=db
|
||||
)
|
||||
if children:
|
||||
for child in children:
|
||||
if child.id not in {r['id'] for r in results}:
|
||||
results.append({
|
||||
**child.model_dump(),
|
||||
'owner_name': owner_cache.get(child.user_id, 'Unknown'),
|
||||
'permission': folder_perms.get(root_id, 'read'),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
############################
|
||||
# Get Folders By Id
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/{id}', response_model=Optional[FolderModel])
|
||||
async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
@router.get('/{id}', response_model=None)
|
||||
async def get_folder_by_id(request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if folder:
|
||||
return folder
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
|
||||
return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]}
|
||||
|
||||
# Check shared access
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)):
|
||||
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
|
||||
return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -137,17 +242,28 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSe
|
||||
|
||||
@router.post('/{id}/update')
|
||||
async def update_folder_name_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: FolderUpdateForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if not folder:
|
||||
# Check shared write access
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if not folder or (user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if folder:
|
||||
if form_data.name is not None:
|
||||
# Check if folder with same name exists
|
||||
existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
folder.parent_id, user.id, form_data.name, db=db
|
||||
folder.parent_id, folder.user_id, form_data.name, db=db
|
||||
)
|
||||
if existing_folder and existing_folder.id != id:
|
||||
raise HTTPException(
|
||||
@@ -166,7 +282,7 @@ async def update_folder_name_by_id(
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db)
|
||||
folder = await Folders.update_folder_by_id_and_user_id(id, folder.user_id, form_data, db=db)
|
||||
return folder
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -175,11 +291,6 @@ async def update_folder_name_by_id(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error updating folder'),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -193,11 +304,13 @@ class FolderParentIdForm(BaseModel):
|
||||
|
||||
@router.post('/{id}/update/parent')
|
||||
async def update_folder_parent_id_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: FolderParentIdForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if folder:
|
||||
existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
@@ -238,11 +351,13 @@ class FolderIsExpandedForm(BaseModel):
|
||||
|
||||
@router.post('/{id}/update/expanded')
|
||||
async def update_folder_is_expanded_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: FolderIsExpandedForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if folder:
|
||||
try:
|
||||
@@ -264,6 +379,108 @@ async def update_folder_is_expanded_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# Update Folder Access By Id
|
||||
############################
|
||||
|
||||
|
||||
class FolderAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post('/{id}/access/update')
|
||||
async def update_folder_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: FolderAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Only owner, admin, or write-granted user can update access
|
||||
if user.role != 'admin' and user.id != folder.user_id:
|
||||
if not await _has_folder_access(user.id, folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id, user.role,
|
||||
form_data.access_grants,
|
||||
None,
|
||||
db=db,
|
||||
)
|
||||
|
||||
await AccessGrants.set_access_grants('folder', id, form_data.access_grants, db=db)
|
||||
|
||||
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
|
||||
return {
|
||||
**folder.model_dump(),
|
||||
'access_grants': [g.model_dump() for g in grants],
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# Get Shared Folder Chats
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/{id}/shared/chats')
|
||||
async def get_shared_folder_chats(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get chats within a shared folder. Returns readonly flag based on permission."""
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
is_owner = user.id == folder.user_id
|
||||
is_admin = user.role == 'admin'
|
||||
has_write = is_owner or is_admin or await _has_folder_access(user.id, folder, 'write', db)
|
||||
has_read = has_write or await _has_folder_access(user.id, folder, 'read', db)
|
||||
|
||||
if not has_read:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
|
||||
|
||||
# Resolve owner names for display (avatar URLs are constructed client-side)
|
||||
owner_cache: dict[str, str] = {}
|
||||
for chat in chats:
|
||||
uid = chat['user_id']
|
||||
if uid not in owner_cache:
|
||||
u = await Users.get_user_by_id(uid, db=db)
|
||||
owner_cache[uid] = u.name if u else 'Unknown'
|
||||
chat['owner_name'] = owner_cache[uid]
|
||||
|
||||
return {
|
||||
'chats': [
|
||||
{**chat, 'readonly': chat['user_id'] != user.id}
|
||||
for chat in chats
|
||||
],
|
||||
'folder_permission': 'write' if has_write else 'read',
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# Delete Folder By Id
|
||||
############################
|
||||
@@ -277,9 +494,37 @@ async def delete_folder_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if await Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not folder:
|
||||
# Check if it's a shared subfolder with write access
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if folder and folder.parent_id:
|
||||
if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
elif folder and not folder.parent_id:
|
||||
# Root shared folders can only be deleted by owner/admin
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
folder_owner_id = folder.user_id
|
||||
|
||||
folder_ids = await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder_owner_id, db=db)
|
||||
if await Chats.count_chats_by_folder_ids_and_user_id(folder_ids, folder_owner_id, db=db):
|
||||
chat_delete_permission = await has_permission(
|
||||
user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'chat.delete', await Config.get('user.permissions'), db=db
|
||||
)
|
||||
if user.role != 'admin' and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
@@ -288,18 +533,21 @@ async def delete_folder_by_id(
|
||||
)
|
||||
|
||||
folders = []
|
||||
folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db))
|
||||
folders.append(folder)
|
||||
while folders:
|
||||
folder = folders.pop()
|
||||
if folder:
|
||||
try:
|
||||
folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db)
|
||||
folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, folder_owner_id, db=db)
|
||||
|
||||
for folder_id in folder_ids:
|
||||
if delete_contents:
|
||||
await Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db)
|
||||
await Chats.delete_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, db=db)
|
||||
else:
|
||||
await Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db)
|
||||
await Chats.move_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, None, db=db)
|
||||
|
||||
# Clean up access grants for this folder
|
||||
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -311,7 +559,7 @@ async def delete_folder_by_id(
|
||||
)
|
||||
finally:
|
||||
# Get all subfolders
|
||||
subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db)
|
||||
subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, folder_owner_id, db=db)
|
||||
folders.extend(subfolders)
|
||||
|
||||
else:
|
||||
|
||||
@@ -9,6 +9,7 @@ import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
@@ -24,7 +25,8 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
|
||||
from open_webui.routers.files import get_file_content_by_id, upload_file_handler
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
@@ -50,17 +52,68 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
IMAGE_CONFIG_KEYS = {
|
||||
'ENABLE_IMAGE_GENERATION': 'image_generation.enable',
|
||||
'ENABLE_IMAGE_PROMPT_GENERATION': 'image_generation.prompt.enable',
|
||||
'IMAGE_GENERATION_ENGINE': 'image_generation.engine',
|
||||
'IMAGE_GENERATION_MODEL': 'image_generation.model',
|
||||
'IMAGE_SIZE': 'image_generation.size',
|
||||
'IMAGE_STEPS': 'image_generation.steps',
|
||||
'IMAGES_OPENAI_API_BASE_URL': 'image_generation.openai.api_base_url',
|
||||
'IMAGES_OPENAI_API_KEY': 'image_generation.openai.api_key',
|
||||
'IMAGES_OPENAI_API_VERSION': 'image_generation.openai.api_version',
|
||||
'IMAGES_OPENAI_API_PARAMS': 'image_generation.openai.params',
|
||||
'AUTOMATIC1111_BASE_URL': 'image_generation.automatic1111.base_url',
|
||||
'AUTOMATIC1111_API_AUTH': 'image_generation.automatic1111.api_auth',
|
||||
'AUTOMATIC1111_PARAMS': 'image_generation.automatic1111.api_params',
|
||||
'COMFYUI_BASE_URL': 'image_generation.comfyui.base_url',
|
||||
'COMFYUI_API_KEY': 'image_generation.comfyui.api_key',
|
||||
'COMFYUI_WORKFLOW': 'image_generation.comfyui.workflow',
|
||||
'COMFYUI_WORKFLOW_NODES': 'image_generation.comfyui.nodes',
|
||||
'IMAGES_GEMINI_API_BASE_URL': 'image_generation.gemini.api_base_url',
|
||||
'IMAGES_GEMINI_API_KEY': 'image_generation.gemini.api_key',
|
||||
'IMAGES_GEMINI_ENDPOINT_METHOD': 'image_generation.gemini.endpoint_method',
|
||||
'ENABLE_IMAGE_EDIT': 'images.edit.enable',
|
||||
'IMAGE_EDIT_ENGINE': 'images.edit.engine',
|
||||
'IMAGE_EDIT_MODEL': 'images.edit.model',
|
||||
'IMAGE_EDIT_SIZE': 'images.edit.size',
|
||||
'IMAGES_EDIT_OPENAI_API_BASE_URL': 'images.edit.openai.api_base_url',
|
||||
'IMAGES_EDIT_OPENAI_API_KEY': 'images.edit.openai.api_key',
|
||||
'IMAGES_EDIT_OPENAI_API_VERSION': 'images.edit.openai.api_version',
|
||||
'IMAGES_EDIT_GEMINI_API_BASE_URL': 'images.edit.gemini.api_base_url',
|
||||
'IMAGES_EDIT_GEMINI_API_KEY': 'images.edit.gemini.api_key',
|
||||
'IMAGES_EDIT_COMFYUI_BASE_URL': 'images.edit.comfyui.base_url',
|
||||
'IMAGES_EDIT_COMFYUI_API_KEY': 'images.edit.comfyui.api_key',
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW': 'images.edit.comfyui.workflow',
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': 'images.edit.comfyui.nodes',
|
||||
'USER_PERMISSIONS': 'user.permissions',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
async def get_image_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(**await get_config_values(IMAGE_CONFIG_KEYS))
|
||||
|
||||
|
||||
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
return {key_map[field]: value for field, value in data.items() if field in key_map}
|
||||
|
||||
|
||||
async def set_image_model(request: Request, model: str):
|
||||
log.info(f'Setting image model to {model}')
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL = model
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
|
||||
api_auth = get_automatic1111_api_auth(request)
|
||||
await Config.upsert({'image_generation.model': model})
|
||||
image_config = await get_image_config()
|
||||
if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
|
||||
api_auth = get_automatic1111_api_auth(image_config)
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': api_auth},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
@@ -68,7 +121,7 @@ async def set_image_model(request: Request, model: str):
|
||||
if model != options['sd_model_checkpoint']:
|
||||
options['sd_model_checkpoint'] = model
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
json=options,
|
||||
headers={'authorization': api_auth},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -77,35 +130,36 @@ async def set_image_model(request: Request, model: str):
|
||||
except Exception as e:
|
||||
log.debug(f'{e}')
|
||||
|
||||
return request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
return image_config.IMAGE_GENERATION_MODEL
|
||||
|
||||
|
||||
async def get_image_model(request):
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
image_config = await get_image_config()
|
||||
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
return (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
image_config.IMAGE_GENERATION_MODEL
|
||||
if image_config.IMAGE_GENERATION_MODEL
|
||||
else 'dall-e-2'
|
||||
)
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
return (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
image_config.IMAGE_GENERATION_MODEL
|
||||
if image_config.IMAGE_GENERATION_MODEL
|
||||
else 'imagen-3.0-generate-002'
|
||||
)
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
return (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL else ''
|
||||
image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else ''
|
||||
)
|
||||
elif (
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or image_config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
try:
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(image_config)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
options = await r.json()
|
||||
@@ -159,52 +213,11 @@ class ImagesConfig(BaseModel):
|
||||
|
||||
@router.get('/config', response_model=ImagesConfig)
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION,
|
||||
'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE,
|
||||
'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE,
|
||||
'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS,
|
||||
'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION,
|
||||
'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS,
|
||||
'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL,
|
||||
'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH,
|
||||
'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS,
|
||||
'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL,
|
||||
'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY,
|
||||
'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW,
|
||||
'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES,
|
||||
'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
|
||||
'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY,
|
||||
'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
|
||||
'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT,
|
||||
'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE,
|
||||
'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL,
|
||||
'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE,
|
||||
'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
|
||||
'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
|
||||
'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
|
||||
'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
|
||||
'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
|
||||
'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
|
||||
}
|
||||
return await get_config_values(IMAGE_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/config/update')
|
||||
async def update_config(request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)):
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION
|
||||
|
||||
# Create Image
|
||||
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION
|
||||
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
|
||||
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
if form_data.IMAGE_SIZE == 'auto' and not re.match(
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
|
||||
):
|
||||
@@ -216,100 +229,31 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends(
|
||||
)
|
||||
|
||||
pattern = r'^\d+x\d+$'
|
||||
if form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE):
|
||||
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
|
||||
else:
|
||||
if not (form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 512x512).'),
|
||||
)
|
||||
|
||||
if form_data.IMAGE_STEPS >= 0:
|
||||
request.app.state.config.IMAGE_STEPS = form_data.IMAGE_STEPS
|
||||
else:
|
||||
if form_data.IMAGE_STEPS < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 50).'),
|
||||
)
|
||||
|
||||
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = form_data.IMAGES_OPENAI_API_BASE_URL
|
||||
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY
|
||||
request.app.state.config.IMAGES_OPENAI_API_VERSION = form_data.IMAGES_OPENAI_API_VERSION
|
||||
request.app.state.config.IMAGES_OPENAI_API_PARAMS = form_data.IMAGES_OPENAI_API_PARAMS
|
||||
|
||||
request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL
|
||||
request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH
|
||||
request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS
|
||||
|
||||
request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip('/')
|
||||
request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY
|
||||
request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW
|
||||
request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES
|
||||
|
||||
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = form_data.IMAGES_GEMINI_API_BASE_URL
|
||||
request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY
|
||||
request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = form_data.IMAGES_GEMINI_ENDPOINT_METHOD
|
||||
|
||||
# Edit Image
|
||||
request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT
|
||||
request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE
|
||||
request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL
|
||||
request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE
|
||||
|
||||
request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = form_data.IMAGES_EDIT_OPENAI_API_BASE_URL
|
||||
request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = form_data.IMAGES_EDIT_OPENAI_API_KEY
|
||||
request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = form_data.IMAGES_EDIT_OPENAI_API_VERSION
|
||||
|
||||
request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = form_data.IMAGES_EDIT_GEMINI_API_BASE_URL
|
||||
request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = form_data.IMAGES_EDIT_GEMINI_API_KEY
|
||||
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/')
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = form_data.IMAGES_EDIT_COMFYUI_API_KEY
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES
|
||||
|
||||
return {
|
||||
'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION,
|
||||
'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE,
|
||||
'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE,
|
||||
'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS,
|
||||
'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION,
|
||||
'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS,
|
||||
'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL,
|
||||
'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH,
|
||||
'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS,
|
||||
'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL,
|
||||
'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY,
|
||||
'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW,
|
||||
'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES,
|
||||
'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
|
||||
'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY,
|
||||
'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
|
||||
'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT,
|
||||
'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE,
|
||||
'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL,
|
||||
'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE,
|
||||
'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
|
||||
'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
|
||||
'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
|
||||
'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
|
||||
'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
|
||||
'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
|
||||
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
|
||||
}
|
||||
updates = config_updates(form_data.model_dump(), IMAGE_CONFIG_KEYS)
|
||||
updates['image_generation.comfyui.base_url'] = form_data.COMFYUI_BASE_URL.strip('/')
|
||||
updates['images.edit.comfyui.base_url'] = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/')
|
||||
await Config.upsert(updates)
|
||||
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
return await get_config_values(IMAGE_CONFIG_KEYS)
|
||||
|
||||
|
||||
def get_automatic1111_api_auth(request: Request):
|
||||
if request.app.state.config.AUTOMATIC1111_API_AUTH is None:
|
||||
def get_automatic1111_api_auth(image_config):
|
||||
if image_config.AUTOMATIC1111_API_AUTH is None:
|
||||
return ''
|
||||
else:
|
||||
auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode('utf-8')
|
||||
auth1111_byte_string = image_config.AUTOMATIC1111_API_AUTH.encode('utf-8')
|
||||
auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string)
|
||||
auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode('utf-8')
|
||||
return f'Basic {auth1111_base64_encoded_string}'
|
||||
@@ -317,26 +261,27 @@ def get_automatic1111_api_auth(request: Request):
|
||||
|
||||
@router.get('/config/url/verify')
|
||||
async def verify_url(request: Request, user=Depends(get_admin_user)):
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111':
|
||||
image_config = await get_image_config()
|
||||
if image_config.IMAGE_GENERATION_ENGINE == 'automatic1111':
|
||||
try:
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(image_config)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
headers = None
|
||||
if request.app.state.config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
if image_config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
|
||||
try:
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
|
||||
url=f'{image_config.COMFYUI_BASE_URL}/object_info',
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
@@ -350,33 +295,34 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
|
||||
|
||||
@router.get('/models')
|
||||
async def get_models(request: Request, user=Depends(get_verified_user)):
|
||||
image_config = await get_image_config()
|
||||
try:
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
return [
|
||||
{'id': 'dall-e-2', 'name': 'DALL·E 2'},
|
||||
{'id': 'dall-e-3', 'name': 'DALL·E 3'},
|
||||
{'id': 'gpt-image-1', 'name': 'GPT-IMAGE 1'},
|
||||
{'id': 'gpt-image-1.5', 'name': 'GPT-IMAGE 1.5'},
|
||||
]
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
return [
|
||||
{'id': 'imagen-3.0-generate-002', 'name': 'imagen-3.0 generate-002'},
|
||||
]
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
# TODO - get models from comfyui
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
|
||||
url=f'{image_config.COMFYUI_BASE_URL}/object_info',
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
info = await r.json()
|
||||
|
||||
workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW)
|
||||
workflow = json.loads(image_config.COMFYUI_WORKFLOW)
|
||||
model_node_id = None
|
||||
|
||||
for node in request.app.state.config.COMFYUI_WORKFLOW_NODES:
|
||||
for node in image_config.COMFYUI_WORKFLOW_NODES:
|
||||
if node['type'] == 'model':
|
||||
if node['node_ids']:
|
||||
model_node_id = node['node_ids'][0]
|
||||
@@ -406,13 +352,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
||||
)
|
||||
)
|
||||
elif (
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or image_config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
|
||||
headers={'authorization': get_automatic1111_api_auth(image_config)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
models = await r.json()
|
||||
@@ -540,14 +486,15 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non
|
||||
|
||||
@router.post('/generations')
|
||||
async def generate_images(request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_IMAGE_GENERATION:
|
||||
image_config = await get_image_config()
|
||||
if not image_config.ENABLE_IMAGE_GENERATION:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'features.image_generation', image_config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -563,13 +510,14 @@ async def image_generations(
|
||||
metadata: dict | None = None,
|
||||
user=None,
|
||||
):
|
||||
image_config = await get_image_config()
|
||||
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
|
||||
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
|
||||
# image model other than gpt-image-1, which is warned about on settings save
|
||||
|
||||
size = '512x512'
|
||||
if request.app.state.config.IMAGE_SIZE and 'x' in request.app.state.config.IMAGE_SIZE:
|
||||
size = request.app.state.config.IMAGE_SIZE
|
||||
if image_config.IMAGE_SIZE and 'x' in image_config.IMAGE_SIZE:
|
||||
size = image_config.IMAGE_SIZE
|
||||
|
||||
if form_data.size and 'x' in form_data.size:
|
||||
size = form_data.size
|
||||
@@ -581,40 +529,40 @@ async def image_generations(
|
||||
model = await get_image_model(request)
|
||||
|
||||
try:
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
headers = {
|
||||
'Authorization': f'Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}',
|
||||
'Authorization': f'Bearer {image_config.IMAGES_OPENAI_API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
url = f'{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations'
|
||||
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
|
||||
url = f'{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}'
|
||||
url = f'{image_config.IMAGES_OPENAI_API_BASE_URL}/images/generations'
|
||||
if image_config.IMAGES_OPENAI_API_VERSION:
|
||||
url = f'{url}?api-version={image_config.IMAGES_OPENAI_API_VERSION}'
|
||||
|
||||
data = {
|
||||
'model': model,
|
||||
'prompt': form_data.prompt,
|
||||
'n': form_data.n,
|
||||
**(
|
||||
{'size': form_data.size or request.app.state.config.IMAGE_SIZE}
|
||||
if (form_data.size or request.app.state.config.IMAGE_SIZE)
|
||||
{'size': form_data.size or image_config.IMAGE_SIZE}
|
||||
if (form_data.size or image_config.IMAGE_SIZE)
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
image_config.IMAGE_GENERATION_MODEL,
|
||||
)
|
||||
else {'response_format': 'b64_json'}
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if not request.app.state.config.IMAGES_OPENAI_API_PARAMS
|
||||
else request.app.state.config.IMAGES_OPENAI_API_PARAMS
|
||||
if not image_config.IMAGES_OPENAI_API_PARAMS
|
||||
else image_config.IMAGES_OPENAI_API_PARAMS
|
||||
),
|
||||
}
|
||||
|
||||
@@ -643,17 +591,17 @@ async def image_generations(
|
||||
images.append({'url': url})
|
||||
return images
|
||||
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': request.app.state.config.IMAGES_GEMINI_API_KEY,
|
||||
'x-goog-api-key': image_config.IMAGES_GEMINI_API_KEY,
|
||||
}
|
||||
|
||||
data = {}
|
||||
|
||||
if (
|
||||
request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == ''
|
||||
or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict'
|
||||
image_config.IMAGES_GEMINI_ENDPOINT_METHOD == ''
|
||||
or image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict'
|
||||
):
|
||||
model = f'{model}:predict'
|
||||
data = {
|
||||
@@ -664,13 +612,13 @@ async def image_generations(
|
||||
},
|
||||
}
|
||||
|
||||
elif request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent':
|
||||
elif image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent':
|
||||
model = f'{model}:generateContent'
|
||||
data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
|
||||
url=f'{image_config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
|
||||
json=data,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -701,7 +649,7 @@ async def image_generations(
|
||||
|
||||
return images
|
||||
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
data = {
|
||||
'prompt': form_data.prompt,
|
||||
'width': width,
|
||||
@@ -709,8 +657,8 @@ async def image_generations(
|
||||
'n': form_data.n,
|
||||
}
|
||||
|
||||
if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None:
|
||||
data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS
|
||||
if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
|
||||
data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
|
||||
|
||||
if form_data.negative_prompt is not None:
|
||||
data['negative_prompt'] = form_data.negative_prompt
|
||||
@@ -719,8 +667,8 @@ async def image_generations(
|
||||
**{
|
||||
'workflow': ComfyUIWorkflow(
|
||||
**{
|
||||
'workflow': request.app.state.config.COMFYUI_WORKFLOW,
|
||||
'nodes': request.app.state.config.COMFYUI_WORKFLOW_NODES,
|
||||
'workflow': image_config.COMFYUI_WORKFLOW,
|
||||
'nodes': image_config.COMFYUI_WORKFLOW_NODES,
|
||||
}
|
||||
),
|
||||
**data,
|
||||
@@ -730,8 +678,8 @@ async def image_generations(
|
||||
model,
|
||||
form_data,
|
||||
str(uuid.uuid4()),
|
||||
request.app.state.config.COMFYUI_BASE_URL,
|
||||
request.app.state.config.COMFYUI_API_KEY,
|
||||
image_config.COMFYUI_BASE_URL,
|
||||
image_config.COMFYUI_API_KEY,
|
||||
)
|
||||
log.debug(f'res: {res}')
|
||||
|
||||
@@ -739,13 +687,13 @@ async def image_generations(
|
||||
|
||||
for image in res['data']:
|
||||
headers = None
|
||||
if request.app.state.config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
if image_config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
|
||||
|
||||
image_data, content_type = await get_image_data(
|
||||
image['url'],
|
||||
headers,
|
||||
trusted_base_url=request.app.state.config.COMFYUI_BASE_URL,
|
||||
trusted_base_url=image_config.COMFYUI_BASE_URL,
|
||||
)
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
@@ -757,8 +705,8 @@ async def image_generations(
|
||||
images.append({'url': url})
|
||||
return images
|
||||
elif (
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or image_config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
if form_data.model:
|
||||
await set_image_model(request, form_data.model)
|
||||
@@ -770,20 +718,20 @@ async def image_generations(
|
||||
'height': height,
|
||||
}
|
||||
|
||||
if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None:
|
||||
data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS
|
||||
if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
|
||||
data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
|
||||
|
||||
if form_data.negative_prompt is not None:
|
||||
data['negative_prompt'] = form_data.negative_prompt
|
||||
|
||||
if request.app.state.config.AUTOMATIC1111_PARAMS:
|
||||
data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS}
|
||||
if image_config.AUTOMATIC1111_PARAMS:
|
||||
data = {**data, **image_config.AUTOMATIC1111_PARAMS}
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
|
||||
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
|
||||
json=data,
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
headers={'authorization': get_automatic1111_api_auth(image_config)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
res = await r.json(content_type=None)
|
||||
@@ -820,23 +768,46 @@ class EditImageForm(BaseModel):
|
||||
|
||||
|
||||
@router.post('/edit')
|
||||
async def edit_images(request: Request, form_data: EditImageForm, user=Depends(get_verified_user)):
|
||||
# Authorize the direct route like /generations and the edit_image tool: enforce the
|
||||
# global image-edit switch and the per-user image-generation permission. The internal
|
||||
# callers (edit_image tool, chat middleware) gate themselves and call image_edits()
|
||||
# directly, so they are unaffected by this wrapper.
|
||||
if not request.app.state.config.ENABLE_IMAGE_EDIT:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
return await image_edits(request, form_data, user=user)
|
||||
|
||||
|
||||
async def image_edits(
|
||||
request: Request,
|
||||
form_data: EditImageForm,
|
||||
metadata: dict | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
image_config = await get_image_config()
|
||||
size = None
|
||||
width, height = None, None
|
||||
metadata = metadata or {}
|
||||
|
||||
if (request.app.state.config.IMAGE_EDIT_SIZE and 'x' in request.app.state.config.IMAGE_EDIT_SIZE) or (
|
||||
if (image_config.IMAGE_EDIT_SIZE and 'x' in image_config.IMAGE_EDIT_SIZE) or (
|
||||
form_data.size and 'x' in form_data.size
|
||||
):
|
||||
size = form_data.size if form_data.size else request.app.state.config.IMAGE_EDIT_SIZE
|
||||
size = form_data.size if form_data.size else image_config.IMAGE_EDIT_SIZE
|
||||
width, height = tuple(map(int, size.split('x')))
|
||||
|
||||
model = request.app.state.config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model
|
||||
model = image_config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model
|
||||
|
||||
try:
|
||||
|
||||
@@ -851,14 +822,16 @@ async def image_edits(
|
||||
# without re-validation would let an attacker reach private IPs via a
|
||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||
validate_url(data)
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
# SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer
|
||||
# that passed validate_url cannot reach an internal address.
|
||||
async with get_ssrf_safe_session() as session:
|
||||
async with session.get(
|
||||
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
||||
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
||||
|
||||
else:
|
||||
file_id = None
|
||||
@@ -903,9 +876,9 @@ async def image_edits(
|
||||
)
|
||||
|
||||
try:
|
||||
if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai':
|
||||
if image_config.IMAGE_EDIT_ENGINE == 'openai':
|
||||
headers = {
|
||||
'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}',
|
||||
'Authorization': f'Bearer {image_config.IMAGES_EDIT_OPENAI_API_KEY}',
|
||||
}
|
||||
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
@@ -921,7 +894,7 @@ async def image_edits(
|
||||
{}
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_EDIT_MODEL,
|
||||
image_config.IMAGE_EDIT_MODEL,
|
||||
)
|
||||
else {'response_format': 'b64_json'}
|
||||
),
|
||||
@@ -935,8 +908,8 @@ async def image_edits(
|
||||
files.append(get_image_file_item(img, 'image[]'))
|
||||
|
||||
url_search_params = ''
|
||||
if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION:
|
||||
url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}'
|
||||
if image_config.IMAGES_EDIT_OPENAI_API_VERSION:
|
||||
url_search_params += f'?api-version={image_config.IMAGES_EDIT_OPENAI_API_VERSION}'
|
||||
|
||||
# Build multipart form data for aiohttp
|
||||
form = aiohttp.FormData()
|
||||
@@ -955,7 +928,7 @@ async def image_edits(
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
|
||||
url=f'{image_config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
|
||||
headers=headers,
|
||||
data=form,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -977,10 +950,10 @@ async def image_edits(
|
||||
images.append({'url': url})
|
||||
return images
|
||||
|
||||
elif request.app.state.config.IMAGE_EDIT_ENGINE == 'gemini':
|
||||
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
|
||||
'x-goog-api-key': image_config.IMAGES_EDIT_GEMINI_API_KEY,
|
||||
}
|
||||
|
||||
model = f'{model}:generateContent'
|
||||
@@ -1010,7 +983,7 @@ async def image_edits(
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
|
||||
url=f'{image_config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
|
||||
json=data,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -1034,7 +1007,7 @@ async def image_edits(
|
||||
|
||||
return images
|
||||
|
||||
elif request.app.state.config.IMAGE_EDIT_ENGINE == 'comfyui':
|
||||
elif image_config.IMAGE_EDIT_ENGINE == 'comfyui':
|
||||
try:
|
||||
files = []
|
||||
if isinstance(form_data.image, str):
|
||||
@@ -1048,8 +1021,8 @@ async def image_edits(
|
||||
for file_item in files:
|
||||
res = await comfyui_upload_image(
|
||||
file_item,
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
image_config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
)
|
||||
comfyui_images.append(res.get('name', file_item[1][0]))
|
||||
except Exception as e:
|
||||
@@ -1068,8 +1041,8 @@ async def image_edits(
|
||||
**{
|
||||
'workflow': ComfyUIWorkflow(
|
||||
**{
|
||||
'workflow': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
|
||||
'nodes': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
|
||||
'workflow': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW,
|
||||
'nodes': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
|
||||
}
|
||||
),
|
||||
**data,
|
||||
@@ -1079,8 +1052,8 @@ async def image_edits(
|
||||
model,
|
||||
form_data,
|
||||
str(uuid.uuid4()),
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
image_config.IMAGES_EDIT_COMFYUI_API_KEY,
|
||||
)
|
||||
log.debug(f'res: {res}')
|
||||
|
||||
@@ -1099,13 +1072,13 @@ async def image_edits(
|
||||
|
||||
for image_url in image_urls:
|
||||
headers = None
|
||||
if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'}
|
||||
if image_config.IMAGES_EDIT_COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {image_config.IMAGES_EDIT_COMFYUI_API_KEY}'}
|
||||
|
||||
image_data, content_type = await get_image_data(
|
||||
image_url,
|
||||
headers,
|
||||
trusted_base_url=request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
)
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
|
||||
@@ -13,6 +13,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.knowledge import (
|
||||
@@ -257,7 +258,7 @@ async def create_new_knowledge(
|
||||
# This prevents holding a connection during embed_knowledge_base_metadata()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'workspace.knowledge', request.app.state.config.USER_PERMISSIONS
|
||||
user.id, 'workspace.knowledge', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -265,7 +266,7 @@ async def create_new_knowledge(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -469,7 +470,7 @@ async def update_knowledge_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -537,7 +538,7 @@ async def update_knowledge_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.memories import Memories, MemoryModel
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
|
||||
@@ -20,6 +21,23 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_memories_permission(user):
|
||||
config = await Config.get_many('memories.enable', 'user.permissions')
|
||||
if not config.get('memories.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.memories', config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetMemories
|
||||
# Let what is remembered here spare someone the cost
|
||||
@@ -33,17 +51,7 @@ async def get_memories(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
return await Memories.get_memories_by_user_id(user.id, db=db)
|
||||
|
||||
@@ -73,17 +81,7 @@ async def add_memory(
|
||||
own short-lived sessions so a connection is not held during the external
|
||||
embedding API call (``EMBEDDING_FUNCTION``), which can take 1-5+ seconds.
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
memory = await Memories.insert_new_memory(user.id, form_data.content)
|
||||
|
||||
@@ -124,17 +122,7 @@ async def query_memory(
|
||||
# Database operations (get_memories_by_user_id) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during EMBEDDING_FUNCTION()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
memories = await Memories.get_memories_by_user_id(user.id)
|
||||
if not memories:
|
||||
@@ -154,7 +142,7 @@ async def query_memory(
|
||||
# same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching
|
||||
# memories are surfaced (distances are normalised to 0→1, higher is
|
||||
# better).
|
||||
relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0)
|
||||
relevance_threshold = await Config.get('rag.relevance_threshold', 0.0)
|
||||
if results and relevance_threshold > 0.0 and results.distances and results.distances[0]:
|
||||
from open_webui.retrieval.vector.main import SearchResult
|
||||
|
||||
@@ -199,17 +187,7 @@ async def reset_memory_from_vector_db(
|
||||
calls simultaneously. With a session held, this could block a connection
|
||||
for MINUTES, completely exhausting the connection pool.
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
|
||||
@@ -250,17 +228,7 @@ async def delete_memory_by_user_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
result = await Memories.delete_memories_by_user_id(user.id, db=db)
|
||||
|
||||
@@ -290,17 +258,7 @@ async def update_memory_by_id(
|
||||
# Database operations (update_memory_by_id_and_user_id) manage their own
|
||||
# short-lived sessions. This prevents holding a connection during
|
||||
# EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds).
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content)
|
||||
if memory is None:
|
||||
@@ -339,17 +297,7 @@ async def delete_memory_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
await check_memories_permission(user)
|
||||
|
||||
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import (
|
||||
ModelAccessListResponse,
|
||||
@@ -60,6 +61,9 @@ def _safe_static_redirect_path(url: str) -> str | None:
|
||||
if decoded == path:
|
||||
break
|
||||
path = decoded
|
||||
# Fail closed: a value still encoded after the cap would be decoded further downstream.
|
||||
if unquote(path) != path:
|
||||
return None
|
||||
if '\x00' in path or '\\' in path:
|
||||
return None
|
||||
if not path.startswith('/'):
|
||||
@@ -227,7 +231,7 @@ async def create_new_model(
|
||||
):
|
||||
"""Create a new workspace model entry."""
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'workspace.models', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -255,7 +259,7 @@ async def create_new_model(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -286,7 +290,7 @@ async def export_models(
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'workspace.models_export',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -319,7 +323,7 @@ async def import_models(
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'workspace.models_import',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -400,7 +404,7 @@ async def import_models(
|
||||
# metadata-only imports.
|
||||
if 'access_grants' in model_data:
|
||||
updated_model.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
updated_model.access_grants,
|
||||
@@ -413,7 +417,7 @@ async def import_models(
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
new_model = ModelForm(**model_data)
|
||||
new_model.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
new_model.access_grants,
|
||||
@@ -674,7 +678,7 @@ async def update_model_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -746,7 +750,7 @@ async def update_model_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
@@ -11,6 +11,7 @@ from open_webui.config import (
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.notes import (
|
||||
NoteForm,
|
||||
@@ -66,7 +67,7 @@ async def get_notes(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -114,7 +115,7 @@ async def get_pinned_notes(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -155,7 +156,7 @@ async def search_notes(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -208,7 +209,7 @@ async def create_new_note(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -216,7 +217,7 @@ async def create_new_note(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -249,7 +250,7 @@ async def get_note_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -308,7 +309,7 @@ async def update_note_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -332,7 +333,7 @@ async def update_note_by_id(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -375,7 +376,7 @@ async def update_note_access_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -399,7 +400,7 @@ async def update_note_access_by_id(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -427,7 +428,7 @@ async def pin_note_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -469,7 +470,7 @@ async def delete_note_by_id(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -31,6 +31,7 @@ from open_webui.env import (
|
||||
)
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
@@ -181,6 +182,32 @@ def get_api_key(idx, url, configs):
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
OLLAMA_CONFIG_KEYS = {
|
||||
'ENABLE_OLLAMA_API': 'ollama.enable',
|
||||
'OLLAMA_BASE_URLS': 'ollama.base_urls',
|
||||
'OLLAMA_API_CONFIGS': 'ollama.api_configs',
|
||||
}
|
||||
|
||||
|
||||
async def get_ollama_config_values() -> dict:
|
||||
values = await Config.get_many(*OLLAMA_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in OLLAMA_CONFIG_KEYS.items() if storage_key in values}
|
||||
|
||||
|
||||
async def get_ollama_runtime_config() -> tuple[bool, list[str], dict]:
|
||||
values = await Config.get_many('ollama.enable', 'ollama.base_urls', 'ollama.api_configs')
|
||||
return (
|
||||
values.get('ollama.enable'),
|
||||
values.get('ollama.base_urls') or [],
|
||||
values.get('ollama.api_configs') or {},
|
||||
)
|
||||
|
||||
|
||||
async def get_ollama_connection(idx: int) -> tuple[str, dict, str | None]:
|
||||
_, base_urls, api_configs = await get_ollama_runtime_config()
|
||||
url = base_urls[idx]
|
||||
return url, resolve_api_config(api_configs, idx, url), get_api_key(idx, url, api_configs)
|
||||
|
||||
|
||||
@router.head('/')
|
||||
@router.get('/')
|
||||
@@ -236,11 +263,7 @@ async def get_config(
|
||||
user=Depends(get_admin_user),
|
||||
) -> dict:
|
||||
"""Return the current Ollama connection configuration."""
|
||||
return {
|
||||
'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API,
|
||||
'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS,
|
||||
'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS,
|
||||
}
|
||||
return await get_ollama_config_values()
|
||||
|
||||
|
||||
class OllamaConfigForm(BaseModel):
|
||||
@@ -258,20 +281,20 @@ async def update_config(
|
||||
user=Depends(get_admin_user),
|
||||
) -> dict:
|
||||
"""Persist updated Ollama connection settings."""
|
||||
request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API
|
||||
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
|
||||
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
|
||||
|
||||
# Prune stale config entries that no longer map to a URL index
|
||||
valid_keys = {str(i) for i in range(len(request.app.state.config.OLLAMA_BASE_URLS))}
|
||||
request.app.state.config.OLLAMA_API_CONFIGS = {
|
||||
k: v for k, v in request.app.state.config.OLLAMA_API_CONFIGS.items() if k in valid_keys
|
||||
}
|
||||
valid_keys = {str(i) for i in range(len(form_data.OLLAMA_BASE_URLS))}
|
||||
api_configs = {k: v for k, v in form_data.OLLAMA_API_CONFIGS.items() if k in valid_keys}
|
||||
|
||||
await Config.upsert(
|
||||
{
|
||||
'ollama.enable': form_data.ENABLE_OLLAMA_API,
|
||||
'ollama.base_urls': form_data.OLLAMA_BASE_URLS,
|
||||
'ollama.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
return {
|
||||
'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API,
|
||||
'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS,
|
||||
'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS,
|
||||
'ENABLE_OLLAMA_API': form_data.ENABLE_OLLAMA_API,
|
||||
'OLLAMA_BASE_URLS': form_data.OLLAMA_BASE_URLS,
|
||||
'OLLAMA_API_CONFIGS': api_configs,
|
||||
}
|
||||
|
||||
|
||||
@@ -293,9 +316,8 @@ def merge_models_lists(model_lists) -> list[dict]:
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def _resolve_api_config(request: Request, idx: int, url: str) -> dict:
|
||||
def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
|
||||
"""Look up the API config for a backend by numeric index, falling back to URL key (legacy)."""
|
||||
api_configs = request.app.state.config.OLLAMA_API_CONFIGS
|
||||
return api_configs.get(str(idx), api_configs.get(url, {}))
|
||||
|
||||
|
||||
@@ -307,15 +329,15 @@ async def get_all_models(request: Request, user: UserModel | None = None):
|
||||
"""Aggregate model tags from every enabled Ollama backend."""
|
||||
log.info('get_all_models()')
|
||||
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
models_dict: dict = {'models': []}
|
||||
request.app.state.OLLAMA_MODELS = {}
|
||||
return models_dict
|
||||
|
||||
# Fan-out tag requests to every backend
|
||||
tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
api_config = _resolve_api_config(request, idx, url)
|
||||
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
|
||||
if not api_config:
|
||||
tasks.append(send_get_request(f'{url}/api/tags', user=user))
|
||||
elif api_config.get('enable', True):
|
||||
@@ -329,8 +351,8 @@ async def get_all_models(request: Request, user: UserModel | None = None):
|
||||
for idx, response in enumerate(responses):
|
||||
if not response:
|
||||
continue
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = _resolve_api_config(request, idx, url)
|
||||
url = (await Config.get('ollama.base_urls', []))[idx]
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
|
||||
|
||||
connection_type = api_config.get('connection_type', 'local')
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
@@ -394,14 +416,14 @@ async def get_ollama_tags(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""List Ollama model tags, optionally from a specific backend."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
if url_idx is None:
|
||||
result = await get_all_models(request, user=user)
|
||||
else:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
result = await send_request(f'{url}/api/tags', 'GET', key=key, user=user)
|
||||
|
||||
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
@@ -416,12 +438,12 @@ async def get_ollama_loaded_models(
|
||||
user=Depends(get_admin_user),
|
||||
) -> dict:
|
||||
"""List models currently loaded in Ollama memory across all backends."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
return {'models': []}
|
||||
|
||||
tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
api_config = _resolve_api_config(request, idx, url)
|
||||
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
|
||||
if not api_config:
|
||||
tasks.append(send_get_request(f'{url}/api/ps', user=user))
|
||||
elif api_config.get('enable', True):
|
||||
@@ -434,7 +456,7 @@ 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((await Config.get('ollama.api_configs', {})), idx, (await Config.get('ollama.base_urls', []))[idx])
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
for m in response.get('models', []):
|
||||
@@ -450,19 +472,19 @@ async def get_ollama_versions(
|
||||
url_idx: int | None = None,
|
||||
):
|
||||
"""Return the lowest Ollama version across all configured backends."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
return {'version': False}
|
||||
|
||||
if url_idx is not None:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
return await send_request(f'{url}/api/version', 'GET')
|
||||
|
||||
# Fan-out to every enabled backend
|
||||
tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
if api_config.get('enable', True):
|
||||
tasks.append(send_get_request(f'{url}/api/version', api_config.get('key')))
|
||||
@@ -511,11 +533,11 @@ async def unload_model(
|
||||
results = []
|
||||
errors = []
|
||||
for idx in url_indices:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
url = (await Config.get('ollama.base_urls', []))[idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(idx), (await Config.get('ollama.api_configs', {})).get(url, {})
|
||||
)
|
||||
key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id and model.startswith(f'{prefix_id}.'):
|
||||
@@ -552,20 +574,20 @@ async def pull_model(
|
||||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data['model'] = form_data.get('model', form_data.get('name'))
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
log.info(f'url: {url}')
|
||||
|
||||
# Admins may pull from any registry
|
||||
return await send_request(
|
||||
f'{url}/api/pull',
|
||||
payload=json.dumps({**form_data, 'insecure': True}),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
)
|
||||
@@ -588,7 +610,7 @@ async def push_model(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
"""Push a local model to a remote registry."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
if url_idx is None:
|
||||
@@ -598,13 +620,13 @@ async def push_model(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = models[form_data.model]['urls'][0]
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
log.debug(f'url: {url}')
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/push',
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
)
|
||||
@@ -627,16 +649,16 @@ async def create_model(
|
||||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.debug(f'form_data: {form_data}')
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/create',
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
)
|
||||
@@ -658,7 +680,7 @@ async def copy_model(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
"""Duplicate an existing model under a new name."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
if url_idx is None:
|
||||
@@ -668,8 +690,8 @@ async def copy_model(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source))
|
||||
url_idx = models[form_data.source]['urls'][0]
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
await send_request(
|
||||
f'{url}/api/copy',
|
||||
@@ -689,7 +711,7 @@ async def delete_model(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
"""Remove a model from an Ollama backend."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
@@ -703,8 +725,8 @@ async def delete_model(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
|
||||
url_idx = models[model]['urls'][0]
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
await send_request(
|
||||
f'{url}/api/delete',
|
||||
@@ -723,7 +745,7 @@ async def show_model_info(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Retrieve model metadata from the Ollama backend."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
@@ -739,8 +761,8 @@ async def show_model_info(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
|
||||
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/show',
|
||||
@@ -770,7 +792,7 @@ async def embed(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Generate embeddings via the Ollama /api/embed endpoint."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.info(f'generate_ollama_batch_embeddings {form_data}')
|
||||
@@ -787,12 +809,12 @@ async def embed(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
@@ -824,7 +846,7 @@ async def embeddings(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Generate embeddings via the legacy Ollama /api/embeddings endpoint."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.info(f'generate_ollama_embeddings {form_data}')
|
||||
@@ -841,12 +863,12 @@ async def embeddings(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
@@ -886,7 +908,7 @@ async def generate_completion(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Run text completion via Ollama /api/generate."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
|
||||
@@ -900,10 +922,10 @@ async def generate_completion(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
@@ -913,7 +935,7 @@ async def generate_completion(
|
||||
return await send_request(
|
||||
f'{url}/api/generate',
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
)
|
||||
@@ -973,7 +995,7 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
|
||||
)
|
||||
url_idx = random.choice(models[model].get('urls', []))
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
return url, url_idx
|
||||
|
||||
|
||||
@@ -986,7 +1008,7 @@ async def generate_chat_completion(
|
||||
user=Depends(get_verified_user), # noqa: B008
|
||||
):
|
||||
"""Forward a chat completion request to an Ollama backend."""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
@@ -1035,7 +1057,7 @@ async def generate_chat_completion(
|
||||
await check_model_access(user, None, bypass_filter)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = _resolve_api_config(request, url_idx, url)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
@@ -1044,7 +1066,7 @@ async def generate_chat_completion(
|
||||
return await send_request(
|
||||
f'{url}/api/chat',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=form_data.stream,
|
||||
content_type='application/x-ndjson',
|
||||
@@ -1121,7 +1143,7 @@ async def generate_openai_completion(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = _resolve_api_config(request, url_idx, url)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
@@ -1130,7 +1152,7 @@ async def generate_openai_completion(
|
||||
return await send_request(
|
||||
f'{url}/v1/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
metadata=metadata,
|
||||
@@ -1178,7 +1200,7 @@ async def generate_openai_chat_completion(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = _resolve_api_config(request, url_idx, url)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
@@ -1187,7 +1209,7 @@ async def generate_openai_chat_completion(
|
||||
return await send_request(
|
||||
f'{url}/v1/chat/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
metadata=metadata,
|
||||
@@ -1211,7 +1233,7 @@ async def generate_anthropic_messages(
|
||||
|
||||
See https://docs.ollama.com/api/anthropic-compatibility
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = {**form_data}
|
||||
@@ -1227,9 +1249,9 @@ async def generate_anthropic_messages(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
@@ -1239,7 +1261,7 @@ async def generate_anthropic_messages(
|
||||
return await send_request(
|
||||
f'{url}/v1/messages',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
@@ -1269,7 +1291,7 @@ async def generate_responses(
|
||||
|
||||
See https://ollama.com/blog/responses-api
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = form_data.model_dump()
|
||||
@@ -1285,9 +1307,9 @@ async def generate_responses(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
@@ -1297,7 +1319,7 @@ async def generate_responses(
|
||||
return await send_request(
|
||||
f'{url}/v1/responses',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
@@ -1317,7 +1339,7 @@ async def get_openai_models(
|
||||
model_list = await get_all_models(request, user=user)
|
||||
raw_models = model_list['models']
|
||||
else:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
model_list = await send_request(f'{url}/api/tags', 'GET')
|
||||
raw_models = model_list.get('models', [])
|
||||
|
||||
@@ -1429,7 +1451,7 @@ async def download_model(
|
||||
detail='Invalid file_url. Only URLs from allowed hosts are permitted.',
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0]
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
|
||||
file_name = parse_huggingface_url(form_data.url)
|
||||
|
||||
if not file_name:
|
||||
@@ -1450,7 +1472,7 @@ async def upload_model(
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
"""Upload a local model file, push it as a blob, and create the model in Ollama."""
|
||||
ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0]
|
||||
ollama_url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
|
||||
|
||||
filename = os.path.basename(file.filename)
|
||||
file_path = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
@@ -34,10 +34,11 @@ from open_webui.env import (
|
||||
)
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.utils.access_control import check_model_access, has_connection_access
|
||||
from open_webui.utils.access_control import check_model_access, has_connection_access, has_permission
|
||||
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||
@@ -236,15 +237,50 @@ def get_microsoft_entra_id_access_token():
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
OPENAI_CONFIG_KEYS = {
|
||||
'ENABLE_OPENAI_API': 'openai.enable',
|
||||
'OPENAI_API_BASE_URLS': 'openai.api_base_urls',
|
||||
'OPENAI_API_KEYS': 'openai.api_keys',
|
||||
'OPENAI_API_CONFIGS': 'openai.api_configs',
|
||||
}
|
||||
|
||||
|
||||
async def get_openai_config() -> dict:
|
||||
values = await Config.get_many(*OPENAI_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in OPENAI_CONFIG_KEYS.items() if storage_key in values}
|
||||
|
||||
|
||||
async def get_openai_runtime_config() -> tuple[bool, list[str], list[str], dict]:
|
||||
values = await Config.get_many('openai.enable', 'openai.api_base_urls', 'openai.api_keys', 'openai.api_configs')
|
||||
return (
|
||||
values.get('openai.enable'),
|
||||
values.get('openai.api_base_urls') or [],
|
||||
values.get('openai.api_keys') or [],
|
||||
values.get('openai.api_configs') or {},
|
||||
)
|
||||
|
||||
|
||||
async def normalize_openai_api_keys(api_base_urls: list[str], api_keys: list[str]) -> list[str]:
|
||||
if len(api_keys) > len(api_base_urls):
|
||||
api_keys = api_keys[: len(api_base_urls)]
|
||||
elif len(api_keys) < len(api_base_urls):
|
||||
api_keys = [*api_keys, *([''] * (len(api_base_urls) - len(api_keys)))]
|
||||
|
||||
await Config.upsert({'openai.api_keys': api_keys})
|
||||
return api_keys
|
||||
|
||||
|
||||
async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
|
||||
_, api_base_urls, api_keys, api_configs = await get_openai_runtime_config()
|
||||
url = api_base_urls[idx]
|
||||
key = api_keys[idx]
|
||||
api_config = api_configs.get(str(idx), api_configs.get(url, {}))
|
||||
return url, key, api_config
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API,
|
||||
'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS,
|
||||
'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS,
|
||||
'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS,
|
||||
}
|
||||
return await get_openai_config()
|
||||
|
||||
|
||||
class OpenAIConfigForm(BaseModel):
|
||||
@@ -256,42 +292,47 @@ class OpenAIConfigForm(BaseModel):
|
||||
|
||||
@router.post('/config/update')
|
||||
async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depends(get_admin_user)):
|
||||
request.app.state.config.ENABLE_OPENAI_API = form_data.ENABLE_OPENAI_API
|
||||
request.app.state.config.OPENAI_API_BASE_URLS = form_data.OPENAI_API_BASE_URLS
|
||||
request.app.state.config.OPENAI_API_KEYS = form_data.OPENAI_API_KEYS
|
||||
api_keys = form_data.OPENAI_API_KEYS
|
||||
|
||||
# Check if API KEYS length is same than API URLS length
|
||||
if len(request.app.state.config.OPENAI_API_KEYS) != len(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
if len(request.app.state.config.OPENAI_API_KEYS) > len(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
request.app.state.config.OPENAI_API_KEYS = request.app.state.config.OPENAI_API_KEYS[
|
||||
: len(request.app.state.config.OPENAI_API_BASE_URLS)
|
||||
]
|
||||
else:
|
||||
request.app.state.config.OPENAI_API_KEYS += [''] * (
|
||||
len(request.app.state.config.OPENAI_API_BASE_URLS) - len(request.app.state.config.OPENAI_API_KEYS)
|
||||
)
|
||||
if len(api_keys) > len(form_data.OPENAI_API_BASE_URLS):
|
||||
api_keys = api_keys[: len(form_data.OPENAI_API_BASE_URLS)]
|
||||
elif len(api_keys) < len(form_data.OPENAI_API_BASE_URLS):
|
||||
api_keys = [*api_keys, *([''] * (len(form_data.OPENAI_API_BASE_URLS) - len(api_keys)))]
|
||||
|
||||
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
|
||||
valid_keys = set(map(str, range(len(form_data.OPENAI_API_BASE_URLS))))
|
||||
api_configs = {key: value for key, value in form_data.OPENAI_API_CONFIGS.items() if key in valid_keys}
|
||||
|
||||
# Remove the API configs that are not in the API URLS
|
||||
keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
|
||||
request.app.state.config.OPENAI_API_CONFIGS = {
|
||||
key: value for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() if key in keys
|
||||
}
|
||||
await Config.upsert(
|
||||
{
|
||||
'openai.enable': form_data.ENABLE_OPENAI_API,
|
||||
'openai.api_base_urls': form_data.OPENAI_API_BASE_URLS,
|
||||
'openai.api_keys': api_keys,
|
||||
'openai.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API,
|
||||
'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS,
|
||||
'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS,
|
||||
'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS,
|
||||
'ENABLE_OPENAI_API': form_data.ENABLE_OPENAI_API,
|
||||
'OPENAI_API_BASE_URLS': form_data.OPENAI_API_BASE_URLS,
|
||||
'OPENAI_API_KEYS': api_keys,
|
||||
'OPENAI_API_CONFIGS': api_configs,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/audio/speech')
|
||||
async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'chat.tts', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
idx = None
|
||||
try:
|
||||
idx = request.app.state.config.OPENAI_API_BASE_URLS.index('https://api.openai.com/v1')
|
||||
_, api_base_urls, _, _ = await get_openai_runtime_config()
|
||||
idx = api_base_urls.index('https://api.openai.com/v1')
|
||||
|
||||
body = await request.body()
|
||||
name = hashlib.sha256(body).hexdigest()
|
||||
@@ -305,12 +346,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
|
||||
@@ -360,29 +396,15 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
|
||||
async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
enable_openai_api, api_base_urls, api_keys, api_configs = await get_openai_runtime_config()
|
||||
if not enable_openai_api:
|
||||
return []
|
||||
|
||||
# Cache config values locally to avoid repeated Redis lookups.
|
||||
# Each access to request.app.state.config.<KEY> triggers a Redis GET;
|
||||
# caching here avoids hundreds of redundant round-trips.
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
api_keys = list(request.app.state.config.OPENAI_API_KEYS)
|
||||
api_configs = request.app.state.config.OPENAI_API_CONFIGS
|
||||
|
||||
# Check if API KEYS length is same than API URLS length
|
||||
num_urls = len(api_base_urls)
|
||||
num_keys = len(api_keys)
|
||||
|
||||
if num_keys != num_urls:
|
||||
# if there are more keys than urls, remove the extra keys
|
||||
if num_keys > num_urls:
|
||||
api_keys = api_keys[:num_urls]
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
# if there are more urls than keys, add empty keys
|
||||
else:
|
||||
api_keys += [''] * (num_urls - num_keys)
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
api_keys = await normalize_openai_api_keys(api_base_urls, api_keys)
|
||||
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
@@ -492,13 +514,10 @@ async def get_filtered_models(models, user, db=None):
|
||||
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
log.info('get_all_models()')
|
||||
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
enable_openai_api, api_base_urls, _, _ = await get_openai_runtime_config()
|
||||
if not enable_openai_api:
|
||||
return {'data': []}
|
||||
|
||||
# Cache config value locally to avoid repeated Redis lookups inside
|
||||
# the nested loop in get_merged_models (one GET per model otherwise).
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
|
||||
def extract_data(response):
|
||||
@@ -569,7 +588,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
@router.get('/models')
|
||||
@router.get('/models/{url_idx}')
|
||||
async def get_models(request: Request, url_idx: int | None = None, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
if not await Config.get('openai.enable'):
|
||||
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
|
||||
|
||||
models = {
|
||||
@@ -579,13 +598,7 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
|
||||
if url_idx is None:
|
||||
models = await get_all_models(request, user=user)
|
||||
else:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[url_idx]
|
||||
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(url_idx)
|
||||
|
||||
r = None
|
||||
async with aiohttp.ClientSession(
|
||||
@@ -1114,13 +1127,7 @@ async def generate_chat_completion(
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Get the API config for the model
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
|
||||
), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
@@ -1135,9 +1142,6 @@ async def generate_chat_completion(
|
||||
'role': user.role,
|
||||
}
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
|
||||
# Check if model is a reasoning model that needs special handling
|
||||
if is_openai_new_model(payload['model']):
|
||||
payload = openai_reasoning_model_handler(payload)
|
||||
@@ -1303,12 +1307,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
if model_id in models:
|
||||
idx = models[model_id]['urlIdx']
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
r = None
|
||||
streaming = False
|
||||
@@ -1426,12 +1425,7 @@ async def responses(
|
||||
if model_id in models:
|
||||
idx = models[model_id]['urlIdx']
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
r = None
|
||||
streaming = False
|
||||
@@ -1535,14 +1529,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
if model_id in models:
|
||||
idx = models[model_id]['urlIdx']
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
|
||||
), # Legacy support
|
||||
)
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
r = None
|
||||
streaming = False
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import (
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.routers.openai import get_all_models_responses
|
||||
from open_webui.utils.auth import get_admin_user
|
||||
from pydantic import BaseModel
|
||||
@@ -51,6 +52,12 @@ def get_sorted_filters(model_id, models):
|
||||
return sorted_filters
|
||||
|
||||
|
||||
async def get_openai_connection(url_idx: int) -> tuple[str, str]:
|
||||
base_urls = await Config.get('openai.api_base_urls', [])
|
||||
api_keys = await Config.get('openai.api_keys', [])
|
||||
return base_urls[url_idx], api_keys[url_idx]
|
||||
|
||||
|
||||
async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
user = {'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role}
|
||||
model_id = payload['model']
|
||||
@@ -69,8 +76,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
if not key:
|
||||
continue
|
||||
@@ -133,8 +139,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
if not key:
|
||||
continue
|
||||
@@ -194,11 +199,12 @@ async def get_pipelines_list(request: Request, user=Depends(get_admin_user)):
|
||||
log.debug(f'get_pipelines_list: get_openai_models_responses returned {responses}')
|
||||
|
||||
urlIdxs = [idx for idx, response in enumerate(responses) if response is not None and 'pipelines' in response]
|
||||
base_urls = await Config.get('openai.api_base_urls', [])
|
||||
|
||||
return {
|
||||
'data': [
|
||||
{
|
||||
'url': request.app.state.config.OPENAI_API_BASE_URLS[urlIdx],
|
||||
'url': base_urls[urlIdx],
|
||||
'idx': urlIdx,
|
||||
}
|
||||
for urlIdx in urlIdxs
|
||||
@@ -233,8 +239,7 @@ async def upload_pipeline(
|
||||
with open(file_path, 'wb') as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
headers = {'Authorization': f'Bearer {key}'}
|
||||
|
||||
@@ -294,8 +299,7 @@ async def add_pipeline(request: Request, form_data: AddPipelineForm, user=Depend
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
@@ -338,8 +342,7 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user=
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.delete(
|
||||
@@ -375,8 +378,7 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user=
|
||||
async def get_pipelines(request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)):
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
@@ -416,8 +418,7 @@ async def get_pipeline_valves(
|
||||
):
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
@@ -457,8 +458,7 @@ async def get_pipeline_valves_spec(
|
||||
):
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
@@ -499,8 +499,7 @@ async def update_pipeline_valves(
|
||||
):
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
|
||||
@@ -7,6 +7,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.prompt_history import (
|
||||
PromptHistories,
|
||||
@@ -149,13 +150,13 @@ async def create_new_prompt(
|
||||
await has_permission(
|
||||
user.id,
|
||||
'workspace.prompts',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
)
|
||||
or await has_permission(
|
||||
user.id,
|
||||
'workspace.prompts_import',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
@@ -165,7 +166,7 @@ async def create_new_prompt(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -281,7 +282,7 @@ async def update_prompt_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -438,7 +439,7 @@ async def update_prompt_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -259,10 +259,6 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
|
||||
enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False)
|
||||
log.info(f'SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}')
|
||||
|
||||
# Handle both ConfigVar and direct value
|
||||
if hasattr(enable_scim, 'value'):
|
||||
enable_scim = enable_scim.value
|
||||
|
||||
if not enable_scim:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -271,9 +267,6 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
|
||||
|
||||
# Verify the SCIM token
|
||||
scim_token = getattr(request.app.state, 'SCIM_TOKEN', None)
|
||||
# Handle both ConfigVar and direct value
|
||||
if hasattr(scim_token, 'value'):
|
||||
scim_token = scim_token.value
|
||||
log.debug(f'SCIM token configured: {bool(scim_token)}')
|
||||
if not scim_token or not hmac.compare_digest(token, scim_token):
|
||||
raise HTTPException(
|
||||
@@ -672,7 +665,10 @@ async def update_user(
|
||||
if user_data.emails and len(user_data.emails) > 0:
|
||||
update_data['email'] = user_data.emails[0].value
|
||||
|
||||
if user_data.active is not None:
|
||||
# Do not let SCIM's active flag demote an existing admin: a routine IdP sync or misconfiguration
|
||||
# must not silently strip a locally-provisioned admin's role and lock the instance out. Admin
|
||||
# role changes go through the dedicated admin endpoints, not SCIM provisioning.
|
||||
if user_data.active is not None and user.role != 'admin':
|
||||
update_data['role'] = 'user' if user_data.active else 'pending'
|
||||
|
||||
if user_data.photos and len(user_data.photos) > 0:
|
||||
@@ -719,7 +715,9 @@ async def patch_user(
|
||||
|
||||
if op == 'replace':
|
||||
if path == 'active':
|
||||
update_data['role'] = 'user' if value else 'pending'
|
||||
# Same guard as update_user: never demote an existing admin via SCIM.
|
||||
if user.role != 'admin':
|
||||
update_data['role'] = 'user' if value else 'pending'
|
||||
elif path == 'userName':
|
||||
update_data['email'] = value
|
||||
elif path == 'displayName':
|
||||
|
||||
@@ -6,6 +6,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.skills import (
|
||||
SkillAccessListResponse,
|
||||
@@ -130,7 +131,7 @@ async def export_skills(
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'workspace.skills',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -157,7 +158,7 @@ async def create_new_skill(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, 'workspace.skills', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -179,7 +180,7 @@ async def create_new_skill(
|
||||
# grants in the create payload, bypassing the sharing.public_skills gate
|
||||
# that the dedicated /access/update endpoint already enforces.
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -292,7 +293,7 @@ async def update_skill_by_id(
|
||||
# they may set, so a non-admin owner cannot make their own skill publicly
|
||||
# readable/writable without sharing.public_skills permission.
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -361,7 +362,7 @@ async def update_skill_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
@@ -16,6 +16,7 @@ from open_webui.config import (
|
||||
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES, TASKS
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.routers.pipelines import process_pipeline_inlet_filter
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
@@ -36,6 +37,36 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TASK_CONFIG_KEYS = {
|
||||
'TASK_MODEL': 'task.model.default',
|
||||
'TASK_MODEL_EXTERNAL': 'task.model.external',
|
||||
'TITLE_GENERATION_PROMPT_TEMPLATE': 'task.title.prompt_template',
|
||||
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': 'task.image.prompt_template',
|
||||
'ENABLE_AUTOCOMPLETE_GENERATION': 'task.autocomplete.enable',
|
||||
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': 'task.autocomplete.input_max_length',
|
||||
'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE': 'task.autocomplete.prompt_template',
|
||||
'TAGS_GENERATION_PROMPT_TEMPLATE': 'task.tags.prompt_template',
|
||||
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': 'task.follow_up.prompt_template',
|
||||
'ENABLE_FOLLOW_UP_GENERATION': 'task.follow_up.enable',
|
||||
'ENABLE_TAGS_GENERATION': 'task.tags.enable',
|
||||
'ENABLE_TITLE_GENERATION': 'task.title.enable',
|
||||
'ENABLE_SEARCH_QUERY_GENERATION': 'task.query.search.enable',
|
||||
'ENABLE_RETRIEVAL_QUERY_GENERATION': 'task.query.retrieval.enable',
|
||||
'QUERY_GENERATION_PROMPT_TEMPLATE': 'task.query.prompt_template',
|
||||
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': 'task.tools.prompt_template',
|
||||
'ENABLE_VOICE_MODE_PROMPT': 'task.voice.prompt.enable',
|
||||
'VOICE_MODE_PROMPT_TEMPLATE': 'task.voice.prompt_template',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
values = await Config.get_many(*key_map.values())
|
||||
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
|
||||
|
||||
|
||||
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
return {key_map[field]: value for field, value in data.items() if field in key_map}
|
||||
|
||||
|
||||
##################################
|
||||
#
|
||||
@@ -59,25 +90,7 @@ async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=
|
||||
|
||||
@router.get('/config')
|
||||
async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
return {
|
||||
'TASK_MODEL': request.app.state.config.TASK_MODEL,
|
||||
'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
|
||||
'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION,
|
||||
'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION,
|
||||
'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
|
||||
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
|
||||
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
|
||||
}
|
||||
return await get_config_values(TASK_CONFIG_KEYS)
|
||||
|
||||
|
||||
class TaskConfigForm(BaseModel):
|
||||
@@ -88,6 +101,7 @@ class TaskConfigForm(BaseModel):
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
|
||||
ENABLE_AUTOCOMPLETE_GENERATION: bool
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
|
||||
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE: str
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE: str
|
||||
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE: str
|
||||
ENABLE_FOLLOW_UP_GENERATION: bool
|
||||
@@ -102,56 +116,13 @@ class TaskConfigForm(BaseModel):
|
||||
|
||||
@router.post('/config/update')
|
||||
async def update_task_config(request: Request, form_data: TaskConfigForm, user=Depends(get_admin_user)):
|
||||
request.app.state.config.TASK_MODEL = form_data.TASK_MODEL
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL
|
||||
request.app.state.config.ENABLE_TITLE_GENERATION = form_data.ENABLE_TITLE_GENERATION
|
||||
request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = form_data.TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = form_data.ENABLE_FOLLOW_UP_GENERATION
|
||||
request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = form_data.ENABLE_AUTOCOMPLETE_GENERATION
|
||||
request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = (
|
||||
form_data.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH
|
||||
)
|
||||
|
||||
request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = form_data.TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
request.app.state.config.ENABLE_TAGS_GENERATION = form_data.ENABLE_TAGS_GENERATION
|
||||
request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION = form_data.ENABLE_SEARCH_QUERY_GENERATION
|
||||
request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = form_data.ENABLE_RETRIEVAL_QUERY_GENERATION
|
||||
|
||||
request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT
|
||||
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE
|
||||
|
||||
return {
|
||||
'TASK_MODEL': request.app.state.config.TASK_MODEL,
|
||||
'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION,
|
||||
'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION,
|
||||
'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
|
||||
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
|
||||
'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
|
||||
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
|
||||
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
|
||||
}
|
||||
await Config.upsert(config_updates(form_data.model_dump(), TASK_CONFIG_KEYS))
|
||||
return await get_config_values(TASK_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/title/completions')
|
||||
async def generate_title(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_TITLE_GENERATION:
|
||||
if not await Config.get('task.title.enable'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'detail': 'Title generation is disabled'},
|
||||
@@ -181,15 +152,16 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating chat title using model {task_model_id} for user {user.email} ')
|
||||
|
||||
if request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != '':
|
||||
template = request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
title_template = await Config.get('task.title.prompt_template')
|
||||
if title_template != '':
|
||||
template = title_template
|
||||
else:
|
||||
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -234,7 +206,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
|
||||
|
||||
@router.post('/follow_up/completions')
|
||||
async def generate_follow_ups(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_FOLLOW_UP_GENERATION:
|
||||
if not await Config.get('task.follow_up.enable'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'detail': 'Follow-up generation is disabled'},
|
||||
@@ -259,15 +231,16 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating chat title using model {task_model_id} for user {user.email} ')
|
||||
|
||||
if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != '':
|
||||
template = request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
follow_up_template = await Config.get('task.follow_up.prompt_template')
|
||||
if follow_up_template != '':
|
||||
template = follow_up_template
|
||||
else:
|
||||
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -303,7 +276,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
|
||||
|
||||
@router.post('/tags/completions')
|
||||
async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_TAGS_GENERATION:
|
||||
if not await Config.get('task.tags.enable'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'detail': 'Tags generation is disabled'},
|
||||
@@ -328,15 +301,16 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating chat tags using model {task_model_id} for user {user.email} ')
|
||||
|
||||
if request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != '':
|
||||
template = request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
tags_template = await Config.get('task.tags.prompt_template')
|
||||
if tags_template != '':
|
||||
template = tags_template
|
||||
else:
|
||||
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -391,15 +365,16 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating image prompt using model {task_model_id} for user {user.email} ')
|
||||
|
||||
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != '':
|
||||
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
image_prompt_template = await Config.get('task.image.prompt_template')
|
||||
if image_prompt_template != '':
|
||||
template = image_prompt_template
|
||||
else:
|
||||
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -437,13 +412,13 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
|
||||
async def generate_queries(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
type = form_data.get('type')
|
||||
if type == 'web_search':
|
||||
if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION:
|
||||
if not await Config.get('task.query.search.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'),
|
||||
)
|
||||
elif type == 'retrieval':
|
||||
if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION:
|
||||
if not await Config.get('task.query.retrieval.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'),
|
||||
@@ -472,15 +447,16 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating {type} queries using model {task_model_id} for user {user.email}')
|
||||
|
||||
if (request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != '':
|
||||
template = request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
query_template = await Config.get('task.query.prompt_template')
|
||||
if query_template.strip() != '':
|
||||
template = query_template
|
||||
else:
|
||||
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -515,7 +491,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
||||
|
||||
@router.post('/auto/completions')
|
||||
async def generate_autocompletion(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION:
|
||||
if not await Config.get('task.autocomplete.enable'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'),
|
||||
@@ -525,11 +501,12 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
||||
prompt = form_data.get('prompt')
|
||||
messages = form_data.get('messages')
|
||||
|
||||
if request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH > 0:
|
||||
if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH:
|
||||
autocomplete_input_max_length = await Config.get('task.autocomplete.input_max_length')
|
||||
if autocomplete_input_max_length > 0:
|
||||
if len(prompt) > autocomplete_input_max_length:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH),
|
||||
detail=ERROR_MESSAGES.INPUT_TOO_LONG(autocomplete_input_max_length),
|
||||
)
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
@@ -551,15 +528,16 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(f'generating autocompletion using model {task_model_id} for user {user.email}')
|
||||
|
||||
if (request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != '':
|
||||
template = request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
|
||||
autocomplete_template = await Config.get('task.autocomplete.prompt_template')
|
||||
if autocomplete_template.strip() != '':
|
||||
template = autocomplete_template
|
||||
else:
|
||||
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
@@ -614,8 +592,8 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, Request, Response, WebSocket
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from open_webui.config import TERMINAL_PROXY_HEADERS
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
@@ -43,6 +44,9 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
||||
if once == decoded:
|
||||
break
|
||||
decoded = once
|
||||
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
|
||||
if unquote(decoded) != decoded:
|
||||
return None
|
||||
had_trailing_slash = decoded.endswith('/')
|
||||
normalized = posixpath.normpath(decoded)
|
||||
# Remove any leading slashes that would reset the base
|
||||
@@ -59,7 +63,7 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
||||
@router.get('/')
|
||||
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
|
||||
"""Return terminal servers the authenticated user has access to."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
return [
|
||||
@@ -84,7 +88,7 @@ async def proxy_terminal(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Proxy a request to the admin terminal server identified by *server_id*."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == server_id), None)
|
||||
|
||||
if connection is None:
|
||||
@@ -232,7 +236,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
return None
|
||||
|
||||
# Resolve terminal server
|
||||
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == server_id), None)
|
||||
|
||||
if connection is None:
|
||||
@@ -282,10 +286,14 @@ async def ws_terminal(
|
||||
|
||||
import urllib.parse
|
||||
|
||||
# Encode session_id as an opaque path segment so it cannot smuggle '?'/'#'/'&' (at any
|
||||
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
|
||||
safe_session_id = urllib.parse.quote(session_id, safe='')
|
||||
|
||||
if policy_id:
|
||||
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{session_id}'
|
||||
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
|
||||
else:
|
||||
upstream_url = f'{ws_base}/api/terminals/{session_id}'
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
if upstream_params:
|
||||
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.tools import (
|
||||
@@ -84,7 +85,7 @@ async def get_tools(
|
||||
server_access_grants = {}
|
||||
for server in await get_tool_servers(request):
|
||||
server_idx = server.get('idx', 0)
|
||||
connections = request.app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
connections = await Config.get('tool_server.connections', [])
|
||||
if server_idx >= len(connections):
|
||||
log.warning(
|
||||
f'Tool server index {server_idx} out of range '
|
||||
@@ -113,7 +114,7 @@ async def get_tools(
|
||||
)
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
for server in await Config.get('tool_server.connections', []):
|
||||
if server.get('type', 'openapi') == 'mcp' and server.get('config', {}).get('enable'):
|
||||
server_id = server.get('info', {}).get('id')
|
||||
auth_type = server.get('auth_type', 'none')
|
||||
@@ -303,7 +304,7 @@ async def export_tools(
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id,
|
||||
'workspace.tools_export',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -331,11 +332,11 @@ async def create_new_tools(
|
||||
):
|
||||
"""Create a new tool from user-supplied Python source code."""
|
||||
if user.role != 'admin' and not (
|
||||
await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
|
||||
or await has_permission(
|
||||
user.id,
|
||||
'workspace.tools_import',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
@@ -356,7 +357,7 @@ async def create_new_tools(
|
||||
if tools is None:
|
||||
try:
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -484,8 +485,8 @@ async def update_tools_by_id(
|
||||
# Content edits trigger exec on load — gate them behind workspace.tools (matches /create).
|
||||
if form_data.content != tools.content:
|
||||
if user.role != 'admin' and not (
|
||||
await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
|
||||
or await has_permission(user.id, 'workspace.tools_import', await Config.get('user.permissions'), db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -503,7 +504,7 @@ async def update_tools_by_id(
|
||||
specs = get_tool_specs(TOOLS[id])
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
@@ -574,7 +575,7 @@ async def update_tool_access_by_id(
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
|
||||
@@ -12,6 +12,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import (
|
||||
@@ -38,7 +39,7 @@ from open_webui.utils.auth import (
|
||||
get_verified_user,
|
||||
validate_password,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -157,7 +158,7 @@ async def get_user_permissisions(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
|
||||
|
||||
return user_permissions
|
||||
|
||||
@@ -201,6 +202,8 @@ class AccessGrantsPermissions(BaseModel):
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
controls: bool = True
|
||||
valves: bool = True
|
||||
system_prompt: bool = True
|
||||
@@ -215,6 +218,7 @@ class ChatPermissions(BaseModel):
|
||||
edit: bool = True
|
||||
share: bool = True
|
||||
export: bool = True
|
||||
import_: bool = Field(default=True, alias='import')
|
||||
stt: bool = True
|
||||
tts: bool = True
|
||||
call: bool = True
|
||||
@@ -236,6 +240,7 @@ class FeaturesPermissions(BaseModel):
|
||||
memories: bool = True
|
||||
automations: bool = False
|
||||
calendar: bool = True
|
||||
webhooks: bool = False
|
||||
|
||||
|
||||
class SettingsPermissions(BaseModel):
|
||||
@@ -253,20 +258,36 @@ class UserPermissions(BaseModel):
|
||||
|
||||
@router.get('/default/permissions', response_model=UserPermissions)
|
||||
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
return {
|
||||
'workspace': WorkspacePermissions(**request.app.state.config.USER_PERMISSIONS.get('workspace', {})),
|
||||
'sharing': SharingPermissions(**request.app.state.config.USER_PERMISSIONS.get('sharing', {})),
|
||||
'access_grants': AccessGrantsPermissions(**request.app.state.config.USER_PERMISSIONS.get('access_grants', {})),
|
||||
'chat': ChatPermissions(**request.app.state.config.USER_PERMISSIONS.get('chat', {})),
|
||||
'features': FeaturesPermissions(**request.app.state.config.USER_PERMISSIONS.get('features', {})),
|
||||
'settings': SettingsPermissions(**request.app.state.config.USER_PERMISSIONS.get('settings', {})),
|
||||
'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})),
|
||||
'sharing': SharingPermissions(**user_permissions.get('sharing', {})),
|
||||
'access_grants': AccessGrantsPermissions(**user_permissions.get('access_grants', {})),
|
||||
'chat': ChatPermissions(**user_permissions.get('chat', {})),
|
||||
'features': FeaturesPermissions(**user_permissions.get('features', {})),
|
||||
'settings': SettingsPermissions(**user_permissions.get('settings', {})),
|
||||
}
|
||||
|
||||
|
||||
@router.post('/default/permissions')
|
||||
async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)):
|
||||
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
|
||||
return request.app.state.config.USER_PERMISSIONS
|
||||
user_permissions = form_data.model_dump(by_alias=True)
|
||||
await Config.upsert({'user.permissions': user_permissions})
|
||||
return user_permissions
|
||||
|
||||
|
||||
@router.get('/default/permissions/defaults', response_model=UserPermissions)
|
||||
async def get_default_user_permissions_defaults(user=Depends(get_admin_user)):
|
||||
from open_webui.config import DEFAULT_USER_PERMISSIONS
|
||||
|
||||
return {
|
||||
'workspace': WorkspacePermissions(**DEFAULT_USER_PERMISSIONS.get('workspace', {})),
|
||||
'sharing': SharingPermissions(**DEFAULT_USER_PERMISSIONS.get('sharing', {})),
|
||||
'access_grants': AccessGrantsPermissions(**DEFAULT_USER_PERMISSIONS.get('access_grants', {})),
|
||||
'chat': ChatPermissions(**DEFAULT_USER_PERMISSIONS.get('chat', {})),
|
||||
'features': FeaturesPermissions(**DEFAULT_USER_PERMISSIONS.get('features', {})),
|
||||
'settings': SettingsPermissions(**DEFAULT_USER_PERMISSIONS.get('settings', {})),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
@@ -303,7 +324,7 @@ async def update_user_settings_by_session_user(
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.direct_tool_servers',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
):
|
||||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
@@ -330,7 +351,7 @@ async def get_user_status_by_session_user(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_USER_STATUS:
|
||||
if not await Config.get('users.enable_status'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
@@ -351,7 +372,7 @@ async def update_user_status_by_session_user(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_USER_STATUS:
|
||||
if not await Config.get('users.enable_status'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.chats import ChatTitleMessagesForm
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
from open_webui.utils.misc import get_gravatar_url
|
||||
@@ -41,27 +42,27 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)):
|
||||
|
||||
@router.post('/code/execute')
|
||||
async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_CODE_EXECUTION:
|
||||
if not await Config.get('code_execution.enable'):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'),
|
||||
)
|
||||
|
||||
if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter':
|
||||
if await Config.get('code_execution.engine') == 'jupyter':
|
||||
output = await execute_code_jupyter(
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
|
||||
await Config.get('code_execution.jupyter.url'),
|
||||
form_data.code,
|
||||
(
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
|
||||
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'token'
|
||||
await Config.get('code_execution.jupyter.auth_token')
|
||||
if await Config.get('code_execution.jupyter.auth') == 'token'
|
||||
else None
|
||||
),
|
||||
(
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
|
||||
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'password'
|
||||
await Config.get('code_execution.jupyter.auth_password')
|
||||
if await Config.get('code_execution.jupyter.auth') == 'password'
|
||||
else None
|
||||
),
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
|
||||
await Config.get('code_execution.jupyter.timeout'),
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
@@ -757,11 +757,13 @@ async def yjs_document_update(sid, data):
|
||||
@sio.on('ydoc:document:leave')
|
||||
async def yjs_document_leave(sid, data):
|
||||
"""Handle user leaving a document"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user: # authenticated session required (parity with sibling handlers)
|
||||
return
|
||||
try:
|
||||
document_id = normalize_document_id(data['document_id'])
|
||||
user_id = data.get('user_id', sid)
|
||||
|
||||
log.info(f'User {user_id} leaving document {document_id}')
|
||||
log.info(f'User {user["id"]} leaving document {document_id}')
|
||||
|
||||
# Remove user from the document
|
||||
await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
|
||||
@@ -769,10 +771,10 @@ async def yjs_document_leave(sid, data):
|
||||
# Leave Socket.IO room
|
||||
await sio.leave_room(sid, f'doc_{document_id}')
|
||||
|
||||
# Notify other users
|
||||
# Notify other users; user_id is the authenticated identity, not client-supplied
|
||||
await sio.emit(
|
||||
'ydoc:user:left',
|
||||
{'document_id': document_id, 'user_id': user_id},
|
||||
{'document_id': document_id, 'user_id': user['id']},
|
||||
room=f'doc_{document_id}',
|
||||
)
|
||||
|
||||
@@ -787,16 +789,21 @@ async def yjs_document_leave(sid, data):
|
||||
@sio.on('ydoc:awareness:update')
|
||||
async def yjs_awareness_update(sid, data):
|
||||
"""Handle awareness updates (cursors, selections, etc.)"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user: # authenticated session required (parity with sibling handlers)
|
||||
return
|
||||
try:
|
||||
document_id = data['document_id']
|
||||
user_id = data.get('user_id', sid)
|
||||
document_id = normalize_document_id(data['document_id'])
|
||||
room = f'doc_{document_id}'
|
||||
if room not in sio.rooms(sid): # must have joined the document first
|
||||
return
|
||||
update = data['update']
|
||||
|
||||
# Broadcast awareness update to all other users in the document
|
||||
# Broadcast to the room; user_id is the authenticated identity, not client-supplied
|
||||
await sio.emit(
|
||||
'ydoc:awareness:update',
|
||||
{'document_id': document_id, 'user_id': user_id, 'update': update},
|
||||
room=f'doc_{document_id}',
|
||||
{'document_id': document_id, 'user_id': user['id'], 'update': update},
|
||||
room=room,
|
||||
skip_sid=sid,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import Request
|
||||
|
||||
from open_webui.models.channels import Channel, ChannelMember, Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.memories import Memories
|
||||
from open_webui.models.messages import Message, Messages
|
||||
@@ -225,10 +226,10 @@ async def search_web(
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
try:
|
||||
engine = __request__.app.state.config.WEB_SEARCH_ENGINE
|
||||
engine = await Config.get('rag.web.search.engine')
|
||||
user = UserModel(**__user__) if __user__ else None
|
||||
|
||||
configured = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT
|
||||
configured = await Config.get('rag.web.search.result_count')
|
||||
max_count = 5 if configured is None else configured
|
||||
count = max(1, min(count, max_count)) if count is not None else max_count
|
||||
|
||||
@@ -266,7 +267,7 @@ async def fetch_url(
|
||||
# Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH)
|
||||
# Guard: content may be None if the web loader silently failed
|
||||
if content is not None:
|
||||
max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None)
|
||||
max_length = await Config.get('rag.web.fetch.max_content_length')
|
||||
if max_length and max_length > 0 and len(content) > max_length:
|
||||
content = content[:max_length] + '\n\n[Content truncated...]'
|
||||
else:
|
||||
@@ -475,7 +476,7 @@ async def execute_code(
|
||||
)
|
||||
code = blocking_code + '\n' + code
|
||||
|
||||
engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
|
||||
engine = await Config.get('code_interpreter.engine', 'pyodide')
|
||||
if engine == 'pyodide':
|
||||
# Execute via frontend pyodide using bidirectional event call
|
||||
if __event_call__ is None:
|
||||
@@ -513,21 +514,22 @@ async def execute_code(
|
||||
|
||||
elif engine == 'jupyter':
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
jupyter_auth = await Config.get('code_interpreter.jupyter.auth')
|
||||
|
||||
output = await execute_code_jupyter(
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
|
||||
await Config.get('code_interpreter.jupyter.url'),
|
||||
code,
|
||||
(
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token'
|
||||
await Config.get('code_interpreter.jupyter.auth_token')
|
||||
if jupyter_auth == 'token'
|
||||
else None
|
||||
),
|
||||
(
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password'
|
||||
await Config.get('code_interpreter.jupyter.auth_password')
|
||||
if jupyter_auth == 'password'
|
||||
else None
|
||||
),
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
|
||||
await Config.get('code_interpreter.jupyter.timeout'),
|
||||
)
|
||||
|
||||
stdout = output.get('stdout', '')
|
||||
|
||||
@@ -114,7 +114,7 @@ async def has_access(
|
||||
Check if a user has the specified permission using an in-memory access_grants list.
|
||||
|
||||
Used for config-driven resources (arena models, tool servers) that store
|
||||
access control as JSON in ConfigVar rather than in the access_grant DB table.
|
||||
access control as JSON config rather than in the access_grant DB table.
|
||||
|
||||
Semantics:
|
||||
- None or [] → private (owner-only, deny all)
|
||||
@@ -321,7 +321,8 @@ async def check_model_access(
|
||||
return
|
||||
|
||||
if model_info:
|
||||
if user.role == 'user':
|
||||
# Enforce for every non-admin role (including pending); never fail open.
|
||||
if user.role != 'admin':
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
@@ -38,25 +38,33 @@ async def has_access_to_file(
|
||||
if file.user_id == user.id:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any knowledge bases the user has access to
|
||||
# Check if the file is associated with any knowledge bases the user has access to.
|
||||
# An object (knowledge base or workspace model) confers write/delete on a file only when
|
||||
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
|
||||
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
|
||||
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user.id or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
if (
|
||||
knowledge_base.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
) and (access_type == 'read' or knowledge_base.user_id == file.user_id):
|
||||
return True
|
||||
|
||||
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id:
|
||||
if knowledge_base.id == knowledge_base_id and (
|
||||
access_type == 'read' or knowledge_base.user_id == file.user_id
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
@@ -78,12 +86,14 @@ async def has_access_to_file(
|
||||
if accessible_ids:
|
||||
return True
|
||||
|
||||
# Check if the file is directly attached to a shared workspace model
|
||||
# Check if the file is directly attached to a shared workspace model (per the ownership
|
||||
# note above, model write is conferred only for files the model owner owns).
|
||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
knowledge_items = getattr(model.meta, 'knowledge', None) or []
|
||||
for item in knowledge_items:
|
||||
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
|
||||
return True
|
||||
if access_type == 'read' or model.user_id == file.user_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.folders import FolderModel, Folders
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def has_folder_access(
|
||||
user_id: str, folder: FolderModel, permission: str, db: AsyncSession
|
||||
) -> bool:
|
||||
"""Check if user has access to folder directly or via ancestor inheritance."""
|
||||
if await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='folder',
|
||||
resource_id=folder.id,
|
||||
permission=permission,
|
||||
db=db,
|
||||
):
|
||||
return True
|
||||
# Check ancestor chain for inherited access
|
||||
if folder.parent_id:
|
||||
parent = await Folders.get_folder_by_id(folder.parent_id, db=db)
|
||||
if parent:
|
||||
return await has_folder_access(user_id, parent, permission, db)
|
||||
return False
|
||||
@@ -89,6 +89,26 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di
|
||||
##############################
|
||||
|
||||
|
||||
def _copy_cache_control(source: dict, target: dict) -> dict:
|
||||
if isinstance(source, dict) and 'cache_control' in source:
|
||||
target['cache_control'] = source['cache_control']
|
||||
return target
|
||||
|
||||
|
||||
def _has_cache_control(blocks: list) -> bool:
|
||||
return any(isinstance(block, dict) and 'cache_control' in block for block in blocks)
|
||||
|
||||
|
||||
def _finalize_openai_content(blocks: list) -> str | list:
|
||||
if not blocks:
|
||||
return ''
|
||||
|
||||
if len(blocks) == 1 and blocks[0].get('type') == 'text' and not _has_cache_control(blocks):
|
||||
return blocks[0].get('text', '')
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
@@ -112,14 +132,21 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
if isinstance(system, str):
|
||||
messages.append({'role': 'system', 'content': system})
|
||||
elif isinstance(system, list):
|
||||
# Anthropic supports system as list of content blocks
|
||||
text_parts = []
|
||||
openai_content = []
|
||||
for block in system:
|
||||
if isinstance(block, dict) and block.get('type') == 'text':
|
||||
text_parts.append(block.get('text', ''))
|
||||
openai_content.append(
|
||||
_copy_cache_control(
|
||||
block,
|
||||
{
|
||||
'type': 'text',
|
||||
'text': block.get('text', ''),
|
||||
},
|
||||
)
|
||||
)
|
||||
elif isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
messages.append({'role': 'system', 'content': '\n'.join(text_parts)})
|
||||
openai_content.append({'type': 'text', 'text': block})
|
||||
messages.append({'role': 'system', 'content': _finalize_openai_content(openai_content)})
|
||||
|
||||
# Convert messages
|
||||
for msg in anthropic_payload.get('messages', []):
|
||||
@@ -138,10 +165,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
|
||||
if block_type == 'text':
|
||||
openai_content.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'text': block.get('text', ''),
|
||||
}
|
||||
_copy_cache_control(
|
||||
block,
|
||||
{
|
||||
'type': 'text',
|
||||
'text': block.get('text', ''),
|
||||
},
|
||||
)
|
||||
)
|
||||
elif block_type == 'image':
|
||||
source = block.get('source', {})
|
||||
@@ -149,19 +179,25 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
media_type = source.get('media_type', 'image/png')
|
||||
data = source.get('data', '')
|
||||
openai_content.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{media_type};base64,{data}',
|
||||
_copy_cache_control(
|
||||
block,
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{media_type};base64,{data}',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
elif source.get('type') == 'url':
|
||||
openai_content.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': source.get('url', '')},
|
||||
}
|
||||
_copy_cache_control(
|
||||
block,
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': source.get('url', '')},
|
||||
},
|
||||
)
|
||||
)
|
||||
elif block_type == 'tool_use':
|
||||
tool_calls.append(
|
||||
@@ -196,10 +232,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
|
||||
if content_type == 'text':
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'text': content_block.get('text', ''),
|
||||
}
|
||||
_copy_cache_control(
|
||||
content_block,
|
||||
{
|
||||
'type': 'text',
|
||||
'text': content_block.get('text', ''),
|
||||
},
|
||||
)
|
||||
)
|
||||
elif content_type == 'image':
|
||||
source = content_block.get('source', {})
|
||||
@@ -207,21 +246,27 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
media_type = source.get('media_type', 'image/png')
|
||||
data = source.get('data', '')
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{media_type};base64,{data}',
|
||||
_copy_cache_control(
|
||||
content_block,
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{media_type};base64,{data}',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
elif source.get('type') == 'url':
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': source.get('url', ''),
|
||||
_copy_cache_control(
|
||||
content_block,
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': source.get('url', ''),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
elif content_type == 'document':
|
||||
# Documents have no direct OpenAI equivalent;
|
||||
@@ -254,7 +299,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
converted_parts.append({'type': 'text', 'text': search_text})
|
||||
|
||||
# Flatten to string when only text parts are present
|
||||
if all(part.get('type') == 'text' for part in converted_parts):
|
||||
if all(part.get('type') == 'text' for part in converted_parts) and not _has_cache_control(
|
||||
converted_parts
|
||||
):
|
||||
tool_content = '\n'.join(part.get('text', '') for part in converted_parts)
|
||||
elif converted_parts:
|
||||
tool_content = converted_parts
|
||||
@@ -287,21 +334,13 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
# Assistant message with tool calls
|
||||
msg_dict = {'role': role}
|
||||
if openai_content:
|
||||
# If there's only text, flatten it
|
||||
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
|
||||
msg_dict['content'] = openai_content[0]['text']
|
||||
else:
|
||||
msg_dict['content'] = openai_content
|
||||
msg_dict['content'] = _finalize_openai_content(openai_content)
|
||||
else:
|
||||
msg_dict['content'] = ''
|
||||
msg_dict['tool_calls'] = tool_calls
|
||||
messages.append(msg_dict)
|
||||
elif openai_content:
|
||||
# If there's only a single text block, flatten it to a string
|
||||
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
|
||||
messages.append({'role': role, 'content': openai_content[0]['text']})
|
||||
else:
|
||||
messages.append({'role': role, 'content': openai_content})
|
||||
messages.append({'role': role, 'content': _finalize_openai_content(openai_content)})
|
||||
else:
|
||||
messages.append({'role': role, 'content': str(content) if content else ''})
|
||||
|
||||
@@ -312,7 +351,7 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
|
||||
|
||||
# Common parameters
|
||||
for param in ('temperature', 'top_p', 'stop_sequences', 'stream'):
|
||||
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
|
||||
if param in anthropic_payload:
|
||||
if param == 'stop_sequences':
|
||||
openai_payload['stop'] = anthropic_payload[param]
|
||||
@@ -324,30 +363,33 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
openai_tools = []
|
||||
for tool in anthropic_payload['tools']:
|
||||
openai_tools.append(
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool.get('name', ''),
|
||||
'description': tool.get('description', ''),
|
||||
'parameters': tool.get('input_schema', {}),
|
||||
_copy_cache_control(
|
||||
tool,
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool.get('name', ''),
|
||||
'description': tool.get('description', ''),
|
||||
'parameters': tool.get('input_schema', {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
openai_payload['tools'] = openai_tools
|
||||
|
||||
# tool_choice
|
||||
if 'tool_choice' in anthropic_payload:
|
||||
tc = anthropic_payload['tool_choice']
|
||||
if isinstance(tc, dict):
|
||||
tc_type = tc.get('type', 'auto')
|
||||
if tc_type == 'auto':
|
||||
tool_choice = anthropic_payload['tool_choice']
|
||||
if isinstance(tool_choice, dict):
|
||||
tool_choice_type = tool_choice.get('type', 'auto')
|
||||
if tool_choice_type == 'auto':
|
||||
openai_payload['tool_choice'] = 'auto'
|
||||
elif tc_type == 'any':
|
||||
elif tool_choice_type == 'any':
|
||||
openai_payload['tool_choice'] = 'required'
|
||||
elif tc_type == 'tool':
|
||||
elif tool_choice_type == 'tool':
|
||||
openai_payload['tool_choice'] = {
|
||||
'type': 'function',
|
||||
'function': {'name': tc.get('name', '')},
|
||||
'function': {'name': tool_choice.get('name', '')},
|
||||
}
|
||||
|
||||
return openai_payload
|
||||
@@ -377,23 +419,23 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
||||
|
||||
# Build content blocks
|
||||
content = []
|
||||
msg_content = message.get('content')
|
||||
if msg_content:
|
||||
content.append({'type': 'text', 'text': msg_content})
|
||||
message_content = message.get('content')
|
||||
if message_content:
|
||||
content.append({'type': 'text', 'text': message_content})
|
||||
|
||||
# Tool calls → tool_use blocks
|
||||
tool_calls = message.get('tool_calls', [])
|
||||
for tc in tool_calls:
|
||||
func = tc.get('function', {})
|
||||
# Tool calls -> tool_use blocks
|
||||
tool_calls = message.get('tool_calls') or []
|
||||
for tool_call in tool_calls:
|
||||
function = tool_call.get('function', {})
|
||||
try:
|
||||
tool_input = json.loads(func.get('arguments', '{}'))
|
||||
tool_input = json.loads(function.get('arguments', '{}'))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tool_input = {}
|
||||
content.append(
|
||||
{
|
||||
'type': 'tool_use',
|
||||
'id': tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'),
|
||||
'name': func.get('name', ''),
|
||||
'id': tool_call.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'),
|
||||
'name': function.get('name', ''),
|
||||
'input': tool_input,
|
||||
}
|
||||
)
|
||||
@@ -404,6 +446,10 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
||||
'input_tokens': openai_usage.get('prompt_tokens', 0),
|
||||
'output_tokens': openai_usage.get('completion_tokens', 0),
|
||||
}
|
||||
if 'cache_creation_input_tokens' in openai_usage:
|
||||
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
|
||||
if 'cache_read_input_tokens' in openai_usage:
|
||||
usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens']
|
||||
|
||||
return {
|
||||
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
|
||||
@@ -426,10 +472,14 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
|
||||
Handles text content, tool calls, and mixed content with proper
|
||||
multi-block indexing as required by Anthropic's streaming protocol.
|
||||
|
||||
Tool calls are tracked by their unique id (not OpenAI index) so that
|
||||
parallel calls sharing the same index get distinct Anthropic tool_use
|
||||
blocks. Each block follows the Anthropic lifecycle: start -> delta -> stop.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
msg_id = f'msg_{_uuid.uuid4().hex[:24]}'
|
||||
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stop_reason = 'end_turn'
|
||||
@@ -439,16 +489,21 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
current_block_index = 0
|
||||
text_block_open = False
|
||||
|
||||
# Track tool call state: maps OpenAI tool_call index -> Anthropic block index
|
||||
# This allows handling multiple concurrent tool calls.
|
||||
tool_call_blocks = {} # {openai_tc_index: anthropic_block_index}
|
||||
tool_call_started = {} # {openai_tc_index: bool}
|
||||
# Accumulated state for each tool call, keyed by tool call id.
|
||||
# Parallel calls that share the same OpenAI index get distinct entries.
|
||||
# Each entry: {id, name, arguments, block_index, started, stopped}
|
||||
tracked_tool_calls = {}
|
||||
# Map OpenAI tool call index -> tool call id for routing
|
||||
# argument-only deltas (deltas that carry arguments but no id).
|
||||
index_to_tool_id = {}
|
||||
# Whether any tool call block has been emitted (suppresses further text)
|
||||
has_tool_calls = False
|
||||
|
||||
# Emit message_start
|
||||
message_start = {
|
||||
'type': 'message_start',
|
||||
'message': {
|
||||
'id': msg_id,
|
||||
'id': message_id,
|
||||
'type': 'message',
|
||||
'role': 'assistant',
|
||||
'content': [],
|
||||
@@ -471,14 +526,14 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
if not line or not line.startswith('data:'):
|
||||
continue
|
||||
|
||||
data_str = line[5:].strip()
|
||||
if data_str == '[DONE]':
|
||||
data_string = line[5:].strip()
|
||||
if data_string == '[DONE]':
|
||||
continue
|
||||
if data_str == '{}':
|
||||
if data_string == '{}':
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
data = json.loads(data_string)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
@@ -492,6 +547,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
|
||||
delta = choices[0].get('delta', {})
|
||||
finish_reason = choices[0].get('finish_reason')
|
||||
message = choices[0].get('message') or {}
|
||||
|
||||
# Update usage if present
|
||||
if data.get('usage'):
|
||||
@@ -499,10 +555,11 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
|
||||
# --- Handle text content ---
|
||||
# Anthropic expects text blocks before tool blocks, so skip
|
||||
# text deltas once any tool call has started.
|
||||
content = delta.get('content')
|
||||
if content is not None:
|
||||
if content and not has_tool_calls:
|
||||
if not text_block_open:
|
||||
# Start a new text content block
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
@@ -511,7 +568,6 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
text_block_open = True
|
||||
|
||||
# Send text delta
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': current_block_index,
|
||||
@@ -520,7 +576,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# --- Handle tool calls ---
|
||||
tool_calls = delta.get('tool_calls')
|
||||
# Some providers put tool_calls on the final message object
|
||||
# instead of the delta; fall back to that when needed.
|
||||
tool_calls = delta.get('tool_calls') or []
|
||||
if not tool_calls and message.get('tool_calls'):
|
||||
tool_calls = message['tool_calls']
|
||||
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if text_block_open:
|
||||
@@ -532,43 +593,95 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
text_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
for tc in tool_calls:
|
||||
tc_index = tc.get('index', 0)
|
||||
for tool_call in tool_calls:
|
||||
tool_call_index = tool_call.get('index', 0)
|
||||
tool_call_id = tool_call.get('id', '')
|
||||
tool_call_name = (tool_call.get('function') or {}).get('name', '')
|
||||
arguments_chunk = (tool_call.get('function') or {}).get('arguments', '')
|
||||
|
||||
if tc_index not in tool_call_started:
|
||||
# First time seeing this tool call — emit content_block_start
|
||||
tool_call_blocks[tc_index] = current_block_index
|
||||
tool_call_started[tc_index] = True
|
||||
# Resolve which tracked tool call this delta belongs to.
|
||||
# A delta with an id starts or identifies a specific tool.
|
||||
# A delta without an id carries arguments for the most
|
||||
# recent tool at this OpenAI index.
|
||||
if tool_call_id:
|
||||
if tool_call_id not in tracked_tool_calls:
|
||||
tracked_tool_calls[tool_call_id] = {
|
||||
'id': tool_call_id,
|
||||
'name': tool_call_name,
|
||||
'arguments': '',
|
||||
'block_index': -1,
|
||||
'started': False,
|
||||
'stopped': False,
|
||||
}
|
||||
index_to_tool_id[tool_call_index] = tool_call_id
|
||||
tool = tracked_tool_calls[tool_call_id]
|
||||
elif tool_call_index in index_to_tool_id:
|
||||
tool = tracked_tool_calls[index_to_tool_id[tool_call_index]]
|
||||
else:
|
||||
# First delta for this index with no id; create a
|
||||
# provisional entry with a generated fallback id.
|
||||
fallback_id = f'toolu_{_uuid.uuid4().hex[:24]}'
|
||||
tracked_tool_calls[fallback_id] = {
|
||||
'id': fallback_id,
|
||||
'name': tool_call_name,
|
||||
'arguments': '',
|
||||
'block_index': -1,
|
||||
'started': False,
|
||||
'stopped': False,
|
||||
}
|
||||
index_to_tool_id[tool_call_index] = fallback_id
|
||||
tool = tracked_tool_calls[fallback_id]
|
||||
|
||||
# Extract tool call ID and name from the first chunk
|
||||
tc_id = tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}')
|
||||
tc_name = tc.get('function', {}).get('name', '')
|
||||
# Update name if provided on a later delta
|
||||
if tool_call_name and not tool['name']:
|
||||
tool['name'] = tool_call_name
|
||||
|
||||
# Emit content_block_start once we have a name
|
||||
if not tool['started'] and tool['name']:
|
||||
tool['block_index'] = current_block_index
|
||||
tool['started'] = True
|
||||
has_tool_calls = True
|
||||
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {
|
||||
'type': 'tool_use',
|
||||
'id': tc_id,
|
||||
'name': tc_name,
|
||||
'id': tool['id'],
|
||||
'name': tool['name'],
|
||||
'input': {},
|
||||
},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
current_block_index += 1
|
||||
|
||||
# Emit argument chunks as input_json_delta
|
||||
args_chunk = tc.get('function', {}).get('arguments', '')
|
||||
if args_chunk:
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': tool_call_blocks[tc_index],
|
||||
'delta': {
|
||||
'type': 'input_json_delta',
|
||||
'partial_json': args_chunk,
|
||||
},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
# Buffer arguments and emit as input_json_delta
|
||||
if arguments_chunk:
|
||||
tool['arguments'] += arguments_chunk
|
||||
|
||||
if tool['started'] and not tool['stopped']:
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': tool['block_index'],
|
||||
'delta': {
|
||||
'type': 'input_json_delta',
|
||||
'partial_json': arguments_chunk,
|
||||
},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# Close the block once arguments form complete JSON
|
||||
if tool['started'] and not tool['stopped']:
|
||||
try:
|
||||
json.loads(tool['arguments'])
|
||||
tool['stopped'] = True
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': tool['block_index'],
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# --- Handle finish reason ---
|
||||
if finish_reason is not None:
|
||||
@@ -582,15 +695,46 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
except Exception as e:
|
||||
log.error(f'Error in Anthropic stream conversion: {e}')
|
||||
|
||||
# Flush any tools that buffered arguments but never emitted a block
|
||||
for tool in tracked_tool_calls.values():
|
||||
if not tool['started'] and tool['name']:
|
||||
tool['block_index'] = current_block_index
|
||||
tool['started'] = True
|
||||
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {
|
||||
'type': 'tool_use',
|
||||
'id': tool['id'],
|
||||
'name': tool['name'],
|
||||
'input': {},
|
||||
},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
current_block_index += 1
|
||||
|
||||
if tool['arguments']:
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': tool['block_index'],
|
||||
'delta': {
|
||||
'type': 'input_json_delta',
|
||||
'partial_json': tool['arguments'],
|
||||
},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# Close any open text block
|
||||
if text_block_open:
|
||||
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
|
||||
# Close any open tool call blocks
|
||||
for tc_index, block_index in tool_call_blocks.items():
|
||||
block_stop = {'type': 'content_block_stop', 'index': block_index}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
# Close any tool call blocks that are still open
|
||||
for tool in tracked_tool_calls.values():
|
||||
if tool['started'] and not tool['stopped']:
|
||||
block_stop = {'type': 'content_block_stop', 'index': tool['block_index']}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
|
||||
# Emit message_delta with stop reason
|
||||
message_delta = {
|
||||
|
||||
@@ -39,6 +39,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.env import CUSTOM_API_KEY_HEADER
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
@@ -165,7 +166,7 @@ class AuthTokenMiddleware:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS
|
||||
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
|
||||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
|
||||
@@ -35,6 +35,7 @@ from open_webui.env import (
|
||||
pk,
|
||||
)
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from pytz import UTC
|
||||
@@ -409,12 +410,16 @@ async def get_current_user_by_api_key(request, api_key: str):
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
|
||||
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
|
||||
|
||||
if not request.state.enable_api_keys or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.api_keys',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user_permissions,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
@@ -422,10 +427,8 @@ async def get_current_user_by_api_key(request, api_key: str):
|
||||
# Enforce endpoint restrictions — checked here (not in middleware)
|
||||
# so it applies regardless of how the API key was transported
|
||||
# (Authorization header, cookie, x-api-key header, etc.).
|
||||
if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
|
||||
allowed_paths = [
|
||||
path.strip() for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip()
|
||||
]
|
||||
if enable_endpoint_restrictions:
|
||||
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
|
||||
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:
|
||||
|
||||
@@ -29,6 +29,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.internal.db import get_async_db
|
||||
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
|
||||
from open_webui.models.chats import ChatForm, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.task import prompt_template
|
||||
from starlette.datastructures import Headers
|
||||
@@ -172,7 +173,7 @@ async def scheduler_worker_loop(app) -> None:
|
||||
while True:
|
||||
try:
|
||||
# ── Automations ──
|
||||
if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False):
|
||||
if await Config.get('automations.enable'):
|
||||
try:
|
||||
async with get_async_db() as db:
|
||||
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
|
||||
@@ -184,7 +185,7 @@ async def scheduler_worker_loop(app) -> None:
|
||||
log.exception('Scheduler: automation error')
|
||||
|
||||
# ── Calendar Alerts ──
|
||||
if getattr(app.state.config, 'ENABLE_CALENDAR', False):
|
||||
if await Config.get('calendar.enable'):
|
||||
try:
|
||||
await _check_calendar_alerts(app)
|
||||
except Exception:
|
||||
@@ -239,7 +240,7 @@ def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
|
||||
return list(tool_ids) if tool_ids else []
|
||||
|
||||
|
||||
def _resolve_model_features(app, model_id: str) -> dict:
|
||||
async def _resolve_model_features(app, model_id: str) -> dict:
|
||||
"""Read model default features from model config.
|
||||
|
||||
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
|
||||
@@ -256,14 +257,13 @@ def _resolve_model_features(app, model_id: str) -> dict:
|
||||
return {}
|
||||
|
||||
capabilities = meta.get('capabilities', {})
|
||||
config = app.state.config
|
||||
features = {}
|
||||
|
||||
# code_interpreter is excluded: it requires the frontend event emitter
|
||||
# and does not work in headless backend execution.
|
||||
feature_checks = {
|
||||
'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False),
|
||||
'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False),
|
||||
'web_search': await Config.get('rag.web.search.enable'),
|
||||
'image_generation': await Config.get('image_generation.enable'),
|
||||
}
|
||||
|
||||
for feature_id in default_feature_ids:
|
||||
@@ -359,6 +359,16 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
await _record_run(automation.id, 'error', error='User not found')
|
||||
return
|
||||
|
||||
# Re-gate the rehydrated owner: a demoted/deactivated or de-permissioned owner must not run.
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
if user.role not in ('user', 'admin') or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(user.id, 'features.automations', await Config.get('user.permissions'))
|
||||
):
|
||||
await _record_run(automation.id, 'error', error='Owner no longer permitted to run automations')
|
||||
return
|
||||
|
||||
prompt = await prompt_template(automation.data['prompt'], user)
|
||||
model_id = automation.data['model_id']
|
||||
terminal_config = automation.data.get('terminal')
|
||||
@@ -426,7 +436,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
|
||||
# Resolve model defaults (frontend does this, backend doesn't)
|
||||
tool_ids = _resolve_model_tool_ids(app, model_id)
|
||||
features = _resolve_model_features(app, model_id)
|
||||
features = await _resolve_model_features(app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(app, model_id)
|
||||
|
||||
# Resolve terminal from model config
|
||||
@@ -551,7 +561,7 @@ async def _check_calendar_alerts(app) -> None:
|
||||
# Send webhook notification if user has one configured
|
||||
try:
|
||||
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False)
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
if enable_user_webhooks:
|
||||
user = await Users.get_user_by_id(event.user_id)
|
||||
|
||||
@@ -237,6 +237,12 @@ async def generate_chat_completion(
|
||||
|
||||
form_data['model'] = selected_model_id
|
||||
|
||||
# bypass_filter recursion below skips the line-200 check; gate the resolved model here.
|
||||
if not bypass_filter and user.role == 'user':
|
||||
selected_model = request.app.state.MODELS.get(selected_model_id)
|
||||
if selected_model:
|
||||
await check_model_access(user, selected_model)
|
||||
|
||||
if selected_model_id:
|
||||
if form_data.get('stream') == True:
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from open_webui.env import (
|
||||
)
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.routers.images import (
|
||||
@@ -28,7 +28,6 @@ from open_webui.routers.images import (
|
||||
upload_image,
|
||||
)
|
||||
from open_webui.storage.provider import Storage
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
|
||||
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
|
||||
@@ -60,16 +59,17 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
||||
# without re-validation would let an attacker reach private IPs via a
|
||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||
validate_url(url)
|
||||
# Download the image from the URL
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
image_data = await response.read()
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
# Fetch through an SSRF-safe session that re-checks the connect-time IP, so a
|
||||
# rebinding DNS answer that passed validate_url cannot reach an internal address.
|
||||
async with get_ssrf_safe_session() as session:
|
||||
async with session.get(
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
image_data = await response.read()
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
else:
|
||||
# Non-URL string — treat as file_id. Delegate to the canonical
|
||||
# file-ID resolver which enforces ownership/access checks.
|
||||
|
||||
@@ -64,9 +64,18 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -
|
||||
return {}
|
||||
|
||||
metadata = metadata or {}
|
||||
|
||||
# Extract user_message info for tree mapping
|
||||
user_message = metadata.get('user_message') or {}
|
||||
user_message_id = metadata.get('user_message_id', '') or (user_message.get('id', '') if user_message else '')
|
||||
user_message_parent_id = user_message.get('parentId', '') if user_message else ''
|
||||
|
||||
template_vars = {
|
||||
'{{CHAT_ID}}': metadata.get('chat_id', '') or '',
|
||||
'{{MESSAGE_ID}}': metadata.get('message_id', '') or '',
|
||||
'{{USER_MESSAGE_ID}}': user_message_id or '',
|
||||
'{{USER_MESSAGE_PARENT_ID}}': user_message_parent_id or '',
|
||||
'{{TASK}}': metadata.get('task', '') or '',
|
||||
'{{USER_ID}}': (user.id if user else '') or '',
|
||||
'{{USER_NAME}}': (user.name if user else '') or '',
|
||||
'{{USER_EMAIL}}': (user.email if user else '') or '',
|
||||
|
||||
@@ -40,6 +40,7 @@ from open_webui.env import (
|
||||
RAG_SYSTEM_CONTEXT,
|
||||
)
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
@@ -980,13 +981,13 @@ async def apply_source_context_to_messages(
|
||||
|
||||
if RAG_SYSTEM_CONTEXT:
|
||||
return add_or_update_system_message(
|
||||
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
await rag_template(await Config.get('rag.template'), context, user_message),
|
||||
messages,
|
||||
append=True,
|
||||
)
|
||||
else:
|
||||
return add_or_update_user_message(
|
||||
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
await rag_template(await Config.get('rag.template'), context, user_message),
|
||||
messages,
|
||||
append=False,
|
||||
)
|
||||
@@ -1290,8 +1291,8 @@ async def chat_completion_tools_handler(
|
||||
|
||||
task_model_id = get_task_model_id(
|
||||
body['model'],
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
@@ -1301,8 +1302,8 @@ async def chat_completion_tools_handler(
|
||||
specs = [tool['spec'] for tool in tools.values()]
|
||||
tools_specs = json.dumps(specs, ensure_ascii=False)
|
||||
|
||||
if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != '':
|
||||
template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
if await Config.get('task.tools.prompt_template') != '':
|
||||
template = await Config.get('task.tools.prompt_template')
|
||||
else:
|
||||
template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
|
||||
@@ -1792,7 +1793,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
|
||||
system_message_content = ''
|
||||
|
||||
if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT:
|
||||
if len(input_images) > 0 and await Config.get('images.edit.enable'):
|
||||
# Edit image(s)
|
||||
try:
|
||||
images = await image_edits(
|
||||
@@ -1852,7 +1853,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
|
||||
else:
|
||||
# Create image(s)
|
||||
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
|
||||
if await Config.get('image_generation.prompt.enable'):
|
||||
try:
|
||||
res = await generate_image_prompt(
|
||||
request,
|
||||
@@ -2018,17 +2019,17 @@ async def chat_completion_files_handler(
|
||||
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
|
||||
query, prefix=prefix, user=user
|
||||
),
|
||||
k=request.app.state.config.TOP_K,
|
||||
k=await Config.get('rag.top_k'),
|
||||
reranking_function=(
|
||||
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user))
|
||||
if request.app.state.RERANKING_FUNCTION
|
||||
else None
|
||||
),
|
||||
k_reranker=request.app.state.config.TOP_K_RERANKER,
|
||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
full_context=all_full_context or request.app.state.config.RAG_FULL_CONTEXT,
|
||||
k_reranker=await Config.get('rag.top_k_reranker'),
|
||||
r=await Config.get('rag.relevance_threshold'),
|
||||
hybrid_bm25_weight=await Config.get('rag.hybrid_bm25_weight'),
|
||||
hybrid_search=await Config.get('rag.enable_hybrid_search'),
|
||||
full_context=all_full_context or await Config.get('rag.full_context'),
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -2269,7 +2270,7 @@ async def connect_mcp_server(
|
||||
Returns None if the server is not found or access is denied.
|
||||
"""
|
||||
mcp_server_connection = None
|
||||
for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
for server_connection in await Config.get('tool_server.connections', []):
|
||||
if server_connection.get('type', '') == 'mcp' and server_connection.get('info', {}).get('id') == server_id:
|
||||
mcp_server_connection = server_connection
|
||||
break
|
||||
@@ -2442,8 +2443,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
task_model_id = get_task_model_id(
|
||||
form_data['model'],
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
await Config.get('task.model.default'),
|
||||
await Config.get('task.model.external'),
|
||||
models,
|
||||
)
|
||||
|
||||
@@ -2471,7 +2472,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
if 'files' in folder.data:
|
||||
# Defensive: filter to entries the caller can still read.
|
||||
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
form_data['files'] = [
|
||||
*allowed_files,
|
||||
*form_data.get('files', []),
|
||||
@@ -2485,7 +2486,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
user_message = get_last_user_message(form_data['messages'])
|
||||
model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', False)
|
||||
|
||||
if model_knowledge and metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if model_knowledge and metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'status',
|
||||
@@ -2550,9 +2551,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
extra_params['__features__'] = features
|
||||
if features:
|
||||
if 'voice' in features and features['voice']:
|
||||
if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True):
|
||||
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE:
|
||||
template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE
|
||||
if await Config.get('task.voice.prompt.enable'):
|
||||
if await Config.get('task.voice.prompt_template'):
|
||||
template = await Config.get('task.voice.prompt_template')
|
||||
else:
|
||||
template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE
|
||||
|
||||
@@ -2563,28 +2564,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
if 'memory' in features and features['memory']:
|
||||
# Skip forced memory injection when native FC is enabled - model can use memory tools
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
form_data = await chat_memory_handler(request, form_data, extra_params, user)
|
||||
|
||||
if 'web_search' in features and features['web_search']:
|
||||
# Skip forced RAG web search when native FC is enabled - model can use web_search tool
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
form_data = await chat_web_search_handler(request, form_data, extra_params, user)
|
||||
|
||||
if 'image_generation' in features and features['image_generation']:
|
||||
# Skip forced image generation when native FC is enabled - model can use generate_image tool
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
form_data = await chat_image_generation_handler(request, form_data, extra_params, user)
|
||||
|
||||
if 'code_interpreter' in features and features['code_interpreter']:
|
||||
engine = getattr(request.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
|
||||
engine = await Config.get('code_interpreter.engine', 'pyodide')
|
||||
|
||||
# Skip XML-tag prompt injection when native FC is enabled —
|
||||
# execute_code will be injected as a builtin tool instead
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
prompt = (
|
||||
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != ''
|
||||
await Config.get('code_interpreter.prompt_template')
|
||||
if await Config.get('code_interpreter.prompt_template') != ''
|
||||
else DEFAULT_CODE_INTERPRETER_PROMPT
|
||||
)
|
||||
|
||||
@@ -2617,7 +2618,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
# If the original caller provided tools, use them as-is (skip resolution).
|
||||
# Otherwise, save any tools that filter inlets added for merging later.
|
||||
inlet_filter_tools = None if payload_tools else form_data.get('tools', None)
|
||||
inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None)
|
||||
|
||||
# Skills — extract IDs from message content (<$skillId|label> tags) so
|
||||
# persisted chats work without relying on the frontend to send skill_ids.
|
||||
@@ -2704,10 +2705,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
}
|
||||
form_data['metadata'] = metadata
|
||||
|
||||
# When the caller provides an explicit OpenAI-style `tools` array in the
|
||||
# request body, skip all server-side tool resolution and pass the caller's
|
||||
# tools through to the model unchanged.
|
||||
if not payload_tools:
|
||||
# When the caller provides an explicit `tools` key in the request body,
|
||||
# skip all server-side tool resolution and pass the caller's tools through
|
||||
# unchanged. Sending `tools: []` explicitly opts out of builtin injection.
|
||||
if payload_tools is None:
|
||||
# Server side tools
|
||||
tool_ids = metadata.get('tool_ids', None)
|
||||
# Client side tools
|
||||
@@ -2838,12 +2839,14 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
if mcp_clients:
|
||||
metadata['mcp_clients'] = mcp_clients
|
||||
|
||||
# Inject builtin tools for native function calling based on enabled features and model capability
|
||||
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
|
||||
# Inject builtin tools for native function calling based on enabled features and model capability.
|
||||
# Only inject when the request originates from the UI (identified by session_id).
|
||||
# API callers don't expect hidden tools; they can explicitly request tools via tool_ids.
|
||||
builtin_tools_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get(
|
||||
'builtin_tools', True
|
||||
)
|
||||
if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled:
|
||||
has_session = bool(metadata.get('session_id'))
|
||||
if has_session and metadata.get('params', {}).get('function_calling') != 'legacy' and builtin_tools_enabled:
|
||||
# Add file context to user messages
|
||||
chat_id = metadata.get('chat_id')
|
||||
form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user)
|
||||
@@ -2866,7 +2869,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
# (e.g. pipe functions) can access all tools including MCP and builtins.
|
||||
metadata['tools'] = tools_dict
|
||||
|
||||
if metadata.get('params', {}).get('function_calling') == 'native':
|
||||
if metadata.get('params', {}).get('function_calling') != 'legacy':
|
||||
# If the function calling is native, then call the tools function calling handler
|
||||
form_data['tools'] = [
|
||||
{'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values()
|
||||
@@ -3532,18 +3535,19 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
|
||||
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
|
||||
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
webui_url = await Config.get('webui.url')
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
'url': f'{webui_url}/c/{metadata["chat_id"]}',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3881,14 +3885,14 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
DETECT_CODE_INTERPRETER = (
|
||||
bool(features.get('code_interpreter'))
|
||||
and builtin_tools_meta.get('code_interpreter', True)
|
||||
and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True)
|
||||
and await Config.get('code_interpreter.enable')
|
||||
and model_capabilities.get('code_interpreter', True)
|
||||
and (
|
||||
getattr(user, 'role', None) == 'admin'
|
||||
or await has_permission(
|
||||
getattr(user, 'id', ''),
|
||||
'features.code_interpreter',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -4782,7 +4786,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
source_context = source_context.strip()
|
||||
if source_context:
|
||||
rag_content = await rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
await Config.get('rag.template'),
|
||||
source_context,
|
||||
user_message,
|
||||
)
|
||||
@@ -4976,7 +4980,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
""")
|
||||
code = blocking_code + '\n' + code
|
||||
|
||||
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
|
||||
if await Config.get('code_interpreter.engine') == 'pyodide':
|
||||
ci_output = await event_caller(
|
||||
{
|
||||
'type': 'execute:python',
|
||||
@@ -4988,21 +4992,21 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
},
|
||||
}
|
||||
)
|
||||
elif request.app.state.config.CODE_INTERPRETER_ENGINE == 'jupyter':
|
||||
elif await Config.get('code_interpreter.engine') == 'jupyter':
|
||||
ci_output = await execute_code_jupyter(
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
|
||||
await Config.get('code_interpreter.jupyter.url'),
|
||||
code,
|
||||
(
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
|
||||
if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token'
|
||||
await Config.get('code_interpreter.jupyter.auth_token')
|
||||
if await Config.get('code_interpreter.jupyter.auth') == 'token'
|
||||
else None
|
||||
),
|
||||
(
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
|
||||
if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password'
|
||||
await Config.get('code_interpreter.jupyter.auth_password')
|
||||
if await Config.get('code_interpreter.jupyter.auth') == 'password'
|
||||
else None
|
||||
),
|
||||
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
|
||||
await Config.get('code_interpreter.jupyter.timeout'),
|
||||
)
|
||||
else:
|
||||
ci_output = {'stdout': 'Code interpreter engine not configured.'}
|
||||
@@ -5146,18 +5150,19 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
|
||||
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
|
||||
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
webui_url = await Config.get('webui.url')
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
'url': f'{webui_url}/c/{metadata["chat_id"]}',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,44 @@ def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str |
|
||||
return True
|
||||
|
||||
|
||||
def _host_matches_pattern(host: str, pattern: str) -> bool:
|
||||
"""Match a hostname against a filter entry on DNS label boundaries.
|
||||
|
||||
`pattern` matches `host` when equal or a parent domain of it, so `corp.com`
|
||||
matches `api.corp.com` but not `evilcorp.com`, and an IP literal matches only
|
||||
itself. Avoids the raw-suffix confusion of a plain endswith.
|
||||
"""
|
||||
host = (host or '').strip().lower().rstrip('.')
|
||||
pattern = (pattern or '').strip().lower().rstrip('.')
|
||||
if not host or not pattern:
|
||||
return False
|
||||
return host == pattern or host.endswith('.' + pattern)
|
||||
|
||||
|
||||
def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool:
|
||||
"""Allow/block a hostname (or list of hostnames / resolved IPs) against a
|
||||
WEB_FETCH_FILTER_LIST-style filter, matching on label boundaries.
|
||||
|
||||
Pass a parsed hostname, never a full URL: matching against a URL lets a path
|
||||
component defeat the filter (e.g. ``https://blocked.example/x`` ends with ``/x``,
|
||||
not the blocked host). Entries prefixed with ``!`` are blocked; the rest form an allowlist.
|
||||
"""
|
||||
if not filter_list:
|
||||
return True
|
||||
|
||||
allow_list, block_list = get_allow_block_lists(filter_list)
|
||||
hosts = [host] if isinstance(host, str) else list(host or [])
|
||||
|
||||
if allow_list:
|
||||
if not any(_host_matches_pattern(h, allowed) for h in hosts for allowed in allow_list):
|
||||
return False
|
||||
|
||||
if any(_host_matches_pattern(h, blocked) for h in hosts for blocked in block_list):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_message_list(messages_map, message_id):
|
||||
"""
|
||||
Reconstructs a list of messages in order up to the specified message_id.
|
||||
|
||||
@@ -12,6 +12,7 @@ from open_webui.config import (
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.functions import get_function_models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
@@ -52,14 +53,15 @@ async def fetch_openai_models(request: Request, user: UserModel = None):
|
||||
|
||||
|
||||
async def get_all_base_models(request: Request, user: UserModel = None):
|
||||
config = await Config.get_many('openai.enable', 'ollama.enable')
|
||||
openai_task = (
|
||||
fetch_openai_models(request, user)
|
||||
if request.app.state.config.ENABLE_OPENAI_API
|
||||
if config.get('openai.enable')
|
||||
else asyncio.sleep(0, result=[])
|
||||
)
|
||||
ollama_task = (
|
||||
fetch_ollama_models(request, user)
|
||||
if request.app.state.config.ENABLE_OLLAMA_API
|
||||
if config.get('ollama.enable')
|
||||
else asyncio.sleep(0, result=[])
|
||||
)
|
||||
function_task = get_function_models(request)
|
||||
@@ -70,10 +72,15 @@ async def get_all_base_models(request: Request, user: UserModel = None):
|
||||
|
||||
|
||||
async def get_all_models(request, refresh: bool = False, user: UserModel = None):
|
||||
config = await Config.get_many(
|
||||
'models.base_models_cache',
|
||||
'evaluation.arena.enable',
|
||||
'evaluation.arena.models',
|
||||
)
|
||||
if (
|
||||
request.app.state.MODELS
|
||||
and request.app.state.BASE_MODELS
|
||||
and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
|
||||
and (config.get('models.base_models_cache') and not refresh)
|
||||
):
|
||||
base_models = request.app.state.BASE_MODELS
|
||||
else:
|
||||
@@ -88,9 +95,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
return []
|
||||
|
||||
# Add arena models
|
||||
if request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS:
|
||||
if config.get('evaluation.arena.enable'):
|
||||
arena_models = []
|
||||
if len(request.app.state.config.EVALUATION_ARENA_MODELS) > 0:
|
||||
arena_config = config.get('evaluation.arena.models') or []
|
||||
if len(arena_config) > 0:
|
||||
arena_models = [
|
||||
{
|
||||
'id': model['id'],
|
||||
@@ -103,7 +111,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
'owned_by': 'arena',
|
||||
'arena': True,
|
||||
}
|
||||
for model in request.app.state.config.EVALUATION_ARENA_MODELS
|
||||
for model in arena_config
|
||||
]
|
||||
else:
|
||||
# Add default arena model
|
||||
@@ -289,7 +297,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
|
||||
# Apply global model defaults to all models
|
||||
# Per-model overrides take precedence over global defaults
|
||||
default_metadata = getattr(request.app.state.config, 'DEFAULT_MODEL_METADATA', None) or {}
|
||||
default_metadata = await Config.get('models.default_metadata', {}) or {}
|
||||
|
||||
if default_metadata:
|
||||
for model in models:
|
||||
@@ -368,7 +376,11 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
|
||||
models_dict = {model['id']: model for model in models}
|
||||
if isinstance(request.app.state.MODELS, RedisDict):
|
||||
request.app.state.MODELS.set(models_dict)
|
||||
try:
|
||||
request.app.state.MODELS.set(models_dict)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to update Redis model cache, using in-process cache: {e}')
|
||||
request.app.state.MODELS = models_dict
|
||||
else:
|
||||
request.app.state.MODELS = models_dict
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import base64
|
||||
import copy
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Literal, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -62,7 +61,6 @@ from open_webui.config import (
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
||||
OAUTH_USERNAME_CLAIM,
|
||||
WEBHOOK_URL,
|
||||
AppConfig,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
@@ -78,6 +76,7 @@ from open_webui.env import (
|
||||
WEBUI_NAME,
|
||||
)
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import Users
|
||||
@@ -111,31 +110,73 @@ from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
auth_manager_config = AppConfig()
|
||||
auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
|
||||
auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
|
||||
auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
|
||||
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
|
||||
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
|
||||
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
|
||||
auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE
|
||||
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
|
||||
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
|
||||
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
|
||||
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
|
||||
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
|
||||
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
|
||||
auth_manager_config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
|
||||
auth_manager_config.OAUTH_ALLOWED_ROLES = OAUTH_ALLOWED_ROLES
|
||||
auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES
|
||||
auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
|
||||
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
|
||||
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN
|
||||
auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
|
||||
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
|
||||
OAUTH_RUNTIME_CONFIG = {
|
||||
'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE),
|
||||
'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP),
|
||||
'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': (
|
||||
'oauth.refresh_token.include_scope',
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
|
||||
),
|
||||
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': (
|
||||
'oauth.merge_accounts_by_email',
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
),
|
||||
'ENABLE_OAUTH_ROLE_MANAGEMENT': (
|
||||
'oauth.enable_role_mapping',
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT,
|
||||
),
|
||||
'ENABLE_OAUTH_GROUP_MANAGEMENT': (
|
||||
'oauth.enable_group_mapping',
|
||||
ENABLE_OAUTH_GROUP_MANAGEMENT,
|
||||
),
|
||||
'ENABLE_OAUTH_GROUP_CREATION': (
|
||||
'oauth.enable_group_creation',
|
||||
ENABLE_OAUTH_GROUP_CREATION,
|
||||
),
|
||||
'OAUTH_GROUP_DEFAULT_SHARE': (
|
||||
'oauth.group_default_share',
|
||||
OAUTH_GROUP_DEFAULT_SHARE,
|
||||
),
|
||||
'OAUTH_BLOCKED_GROUPS': ('oauth.blocked_groups', OAUTH_BLOCKED_GROUPS),
|
||||
'OAUTH_ROLES_CLAIM': ('oauth.roles_claim', OAUTH_ROLES_CLAIM),
|
||||
'OAUTH_SUB_CLAIM': ('oauth.sub_claim', OAUTH_SUB_CLAIM),
|
||||
'OAUTH_GROUPS_CLAIM': ('oauth.group_claim', OAUTH_GROUPS_CLAIM),
|
||||
'OAUTH_EMAIL_CLAIM': ('oauth.email_claim', OAUTH_EMAIL_CLAIM),
|
||||
'OAUTH_PICTURE_CLAIM': ('oauth.picture_claim', OAUTH_PICTURE_CLAIM),
|
||||
'OAUTH_USERNAME_CLAIM': ('oauth.username_claim', OAUTH_USERNAME_CLAIM),
|
||||
'OAUTH_ALLOWED_ROLES': ('oauth.allowed_roles', OAUTH_ALLOWED_ROLES),
|
||||
'OAUTH_ADMIN_ROLES': ('oauth.admin_roles', OAUTH_ADMIN_ROLES),
|
||||
'OAUTH_ALLOWED_DOMAINS': ('oauth.allowed_domains', OAUTH_ALLOWED_DOMAINS),
|
||||
'WEBHOOK_URL': ('webhook_url', WEBHOOK_URL),
|
||||
'JWT_EXPIRES_IN': ('auth.jwt_expiry', JWT_EXPIRES_IN),
|
||||
'OAUTH_UPDATE_PICTURE_ON_LOGIN': (
|
||||
'oauth.update_picture_on_login',
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
||||
),
|
||||
'OAUTH_UPDATE_NAME_ON_LOGIN': (
|
||||
'oauth.update_name_on_login',
|
||||
OAUTH_UPDATE_NAME_ON_LOGIN,
|
||||
),
|
||||
'OAUTH_UPDATE_EMAIL_ON_LOGIN': (
|
||||
'oauth.update_email_on_login',
|
||||
OAUTH_UPDATE_EMAIL_ON_LOGIN,
|
||||
),
|
||||
'OAUTH_AUDIENCE': ('oauth.audience', OAUTH_AUDIENCE),
|
||||
}
|
||||
|
||||
|
||||
def _default_value(value):
|
||||
return getattr(value, 'value', value)
|
||||
|
||||
|
||||
async def get_oauth_runtime_config() -> SimpleNamespace:
|
||||
keys = [key for key, _default in OAUTH_RUNTIME_CONFIG.values()]
|
||||
stored = await Config.get_many(*keys)
|
||||
values = {
|
||||
name: stored.get(key, _default_value(default))
|
||||
for name, (key, default) in OAUTH_RUNTIME_CONFIG.items()
|
||||
}
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
# Conservative default when the provider omits both expires_in and expires_at.
|
||||
@@ -423,7 +464,8 @@ async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
oauth_server_metadata = None
|
||||
oauth_server_metadata_url = None
|
||||
|
||||
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
|
||||
webui_url = await Config.get('webui.url')
|
||||
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
|
||||
|
||||
oauth_client_metadata = OAuthClientMetadata(
|
||||
client_name='Open WebUI',
|
||||
@@ -435,6 +477,13 @@ async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
|
||||
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
|
||||
resource = resource_metadata.resource
|
||||
|
||||
# Prefer the resource-specific scopes from the Protected Resource Metadata
|
||||
# (RFC 9728) over the AS's full scopes_supported catalog, for least
|
||||
# privilege. Mirrors the static-credentials flow (#24690).
|
||||
if resource_metadata.scopes_supported:
|
||||
oauth_client_metadata.scope = ' '.join(resource_metadata.scopes_supported)
|
||||
|
||||
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
@@ -542,7 +591,8 @@ async def get_oauth_client_info_with_static_credentials(
|
||||
oauth_server_metadata = None
|
||||
oauth_server_metadata_url = None
|
||||
|
||||
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
|
||||
webui_url = await Config.get('webui.url')
|
||||
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
|
||||
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
|
||||
|
||||
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
|
||||
@@ -629,7 +679,7 @@ class OAuthClientManager:
|
||||
'client_secret': oauth_client_info.client_secret,
|
||||
'client_kwargs': {
|
||||
'follow_redirects': True,
|
||||
**({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}),
|
||||
**({'timeout': int(OAUTH_CLIENT_TIMEOUT)} if OAUTH_CLIENT_TIMEOUT else {}),
|
||||
**({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}),
|
||||
**(
|
||||
{'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method}
|
||||
@@ -661,7 +711,7 @@ class OAuthClientManager:
|
||||
}
|
||||
return self.clients[client_id]
|
||||
|
||||
def ensure_client_from_config(self, client_id):
|
||||
async def ensure_client_from_config(self, client_id):
|
||||
"""
|
||||
Lazy-load an OAuth client from the current TOOL_SERVER_CONNECTIONS
|
||||
config if it hasn't been registered on this node yet.
|
||||
@@ -670,7 +720,7 @@ class OAuthClientManager:
|
||||
return self.clients[client_id]['client']
|
||||
|
||||
try:
|
||||
connections = getattr(self.app.state.config, 'TOOL_SERVER_CONNECTIONS', [])
|
||||
connections = await Config.get('tool_server.connections', [])
|
||||
except Exception:
|
||||
connections = []
|
||||
|
||||
@@ -776,22 +826,22 @@ class OAuthClientManager:
|
||||
|
||||
return True
|
||||
|
||||
def get_client(self, client_id):
|
||||
async def get_client(self, client_id):
|
||||
if client_id not in self.clients:
|
||||
self.ensure_client_from_config(client_id)
|
||||
await self.ensure_client_from_config(client_id)
|
||||
|
||||
client = self.clients.get(client_id)
|
||||
return client['client'] if client else None
|
||||
|
||||
def get_client_info(self, client_id):
|
||||
async def get_client_info(self, client_id):
|
||||
if client_id not in self.clients:
|
||||
self.ensure_client_from_config(client_id)
|
||||
await self.ensure_client_from_config(client_id)
|
||||
|
||||
client = self.clients.get(client_id)
|
||||
return client['client_info'] if client else None
|
||||
|
||||
def get_server_metadata_url(self, client_id):
|
||||
client = self.get_client(client_id)
|
||||
async def get_server_metadata_url(self, client_id):
|
||||
client = await self.get_client(client_id)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
@@ -874,6 +924,7 @@ class OAuthClientManager:
|
||||
Returns:
|
||||
dict: New token data, or None if refresh failed
|
||||
"""
|
||||
auth_config = await get_oauth_runtime_config()
|
||||
client_id = session.provider
|
||||
token_data = session.token
|
||||
|
||||
@@ -882,14 +933,14 @@ class OAuthClientManager:
|
||||
return None
|
||||
|
||||
try:
|
||||
client = self.get_client(client_id)
|
||||
client = await self.get_client(client_id)
|
||||
if not client:
|
||||
log.error(f'No OAuth client found for provider {client_id}')
|
||||
return None
|
||||
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(self.get_server_metadata_url(client_id)) as r:
|
||||
async with session_http.get(await self.get_server_metadata_url(client_id)) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get('token_endpoint')
|
||||
@@ -906,7 +957,7 @@ class OAuthClientManager:
|
||||
'client_id': client.client_id,
|
||||
}
|
||||
# RFC 8707: include resource indicator so refreshed tokens retain correct audience
|
||||
client_info = self.get_client_info(client_id)
|
||||
client_info = await self.get_client_info(client_id)
|
||||
if client_info and client_info.resource:
|
||||
refresh_data['resource'] = client_info.resource
|
||||
|
||||
@@ -917,7 +968,7 @@ class OAuthClientManager:
|
||||
if (
|
||||
hasattr(client, 'client_kwargs')
|
||||
and client.client_kwargs.get('scope')
|
||||
and getattr(self.app.state.config, 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', False)
|
||||
and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
):
|
||||
refresh_data['scope'] = client.client_kwargs['scope']
|
||||
|
||||
@@ -950,13 +1001,13 @@ class OAuthClientManager:
|
||||
return None
|
||||
|
||||
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
|
||||
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
|
||||
client = await self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
client_info = self.get_client_info(client_id)
|
||||
client_info = await self.get_client_info(client_id)
|
||||
if client_info is None:
|
||||
# ensure_client_from_config registers client_info too
|
||||
client_info = self.get_client_info(client_id)
|
||||
# get_client registers client_info too
|
||||
client_info = await self.get_client_info(client_id)
|
||||
if client_info is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
@@ -969,13 +1020,13 @@ class OAuthClientManager:
|
||||
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
|
||||
|
||||
async def handle_callback(self, request, client_id: str, user_id: str, response):
|
||||
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
|
||||
client = await self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
error_message = None
|
||||
try:
|
||||
client_info = self.get_client_info(client_id)
|
||||
client_info = await self.get_client_info(client_id)
|
||||
|
||||
# Note: Do NOT pass client_id/client_secret explicitly here.
|
||||
# The Authlib client already has these configured during add_client().
|
||||
@@ -1028,7 +1079,8 @@ class OAuthClientManager:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
redirect_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
|
||||
webui_url = await Config.get('webui.url')
|
||||
redirect_url = (str(webui_url or request.base_url)).rstrip('/')
|
||||
|
||||
if error_message:
|
||||
log.debug(error_message)
|
||||
@@ -1195,7 +1247,7 @@ class OAuthManager:
|
||||
if (
|
||||
hasattr(client, 'client_kwargs')
|
||||
and client.client_kwargs.get('scope')
|
||||
and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
):
|
||||
refresh_data['scope'] = client.client_kwargs['scope']
|
||||
|
||||
@@ -1228,6 +1280,7 @@ class OAuthManager:
|
||||
return None
|
||||
|
||||
async def get_user_role(self, user, user_data):
|
||||
auth_config = await get_oauth_runtime_config()
|
||||
user_count = await Users.get_num_users()
|
||||
if user and user_count == 1:
|
||||
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
|
||||
@@ -1239,16 +1292,16 @@ class OAuthManager:
|
||||
# default role here (not 'admin') — admin promotion happens
|
||||
# race-safely *after* insert via get_num_users() == 1.
|
||||
log.debug('First user bootstrap: using default role (admin promotion deferred to post-insert)')
|
||||
return auth_manager_config.DEFAULT_USER_ROLE
|
||||
return auth_config.DEFAULT_USER_ROLE
|
||||
|
||||
if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT:
|
||||
if auth_config.ENABLE_OAUTH_ROLE_MANAGEMENT:
|
||||
log.debug('Running OAUTH Role management')
|
||||
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
|
||||
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
|
||||
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
|
||||
oauth_claim = auth_config.OAUTH_ROLES_CLAIM
|
||||
oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES
|
||||
oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES
|
||||
oauth_roles = []
|
||||
# Default/fallback role if no matching roles are found
|
||||
role = auth_manager_config.DEFAULT_USER_ROLE
|
||||
role = auth_config.DEFAULT_USER_ROLE
|
||||
|
||||
# Next block extracts the roles from the user data, accepting nested claims of any depth
|
||||
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
|
||||
@@ -1306,7 +1359,7 @@ class OAuthManager:
|
||||
else:
|
||||
if not user:
|
||||
# If role management is disabled, use the default role for new users
|
||||
role = auth_manager_config.DEFAULT_USER_ROLE
|
||||
role = auth_config.DEFAULT_USER_ROLE
|
||||
else:
|
||||
# If role management is disabled, use the existing role for existing users
|
||||
role = user.role
|
||||
@@ -1314,11 +1367,12 @@ class OAuthManager:
|
||||
return role
|
||||
|
||||
async def update_user_groups(self, user, user_data, default_permissions, db=None):
|
||||
auth_config = await get_oauth_runtime_config()
|
||||
log.debug('Running OAUTH Group management')
|
||||
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
|
||||
oauth_claim = auth_config.OAUTH_GROUPS_CLAIM
|
||||
|
||||
try:
|
||||
blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS)
|
||||
blocked_groups = json.loads(auth_config.OAUTH_BLOCKED_GROUPS)
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading OAUTH_BLOCKED_GROUPS: {e}')
|
||||
blocked_groups = []
|
||||
@@ -1346,7 +1400,7 @@ class OAuthManager:
|
||||
all_available_groups: list[GroupModel] = await Groups.get_all_groups(db=db)
|
||||
|
||||
# Create groups if they don't exist and creation is enabled
|
||||
if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
|
||||
if auth_config.ENABLE_OAUTH_GROUP_CREATION:
|
||||
log.debug('Checking for missing groups to create...')
|
||||
all_group_names = {g.name for g in all_available_groups}
|
||||
groups_created = False
|
||||
@@ -1363,7 +1417,7 @@ class OAuthManager:
|
||||
name=group_name,
|
||||
description=f"Group '{group_name}' created automatically via OAuth.",
|
||||
permissions=default_permissions, # Use default permissions from function args
|
||||
data={'config': {'share': auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE}},
|
||||
data={'config': {'share': auth_config.OAUTH_GROUP_DEFAULT_SHARE}},
|
||||
)
|
||||
# Use determined creator ID (admin or fallback to current user)
|
||||
created_group = await Groups.insert_new_group(creator_id, new_group_form, db=db)
|
||||
@@ -1489,6 +1543,7 @@ class OAuthManager:
|
||||
return '/user.png'
|
||||
|
||||
async def handle_login(self, request, provider):
|
||||
auth_config = await get_oauth_runtime_config()
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(404)
|
||||
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
|
||||
@@ -1500,14 +1555,15 @@ class OAuthManager:
|
||||
)
|
||||
|
||||
kwargs = {}
|
||||
if auth_manager_config.OAUTH_AUDIENCE:
|
||||
kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE
|
||||
if auth_config.OAUTH_AUDIENCE:
|
||||
kwargs['audience'] = auth_config.OAUTH_AUDIENCE
|
||||
if OAUTH_AUTHORIZE_PARAMS:
|
||||
kwargs.update(OAUTH_AUTHORIZE_PARAMS)
|
||||
|
||||
return await client.authorize_redirect(request, redirect_uri, **kwargs)
|
||||
|
||||
async def handle_callback(self, request, provider, response, db=None):
|
||||
auth_config = await get_oauth_runtime_config()
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(404)
|
||||
|
||||
@@ -1561,8 +1617,8 @@ class OAuthManager:
|
||||
id_token_claims = dict(user_data) if user_data else {}
|
||||
if (
|
||||
(not user_data)
|
||||
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
|
||||
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
|
||||
or (auth_config.OAUTH_EMAIL_CLAIM not in user_data)
|
||||
or (auth_config.OAUTH_USERNAME_CLAIM not in user_data)
|
||||
):
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
# Merge back ID token claims that the userinfo endpoint doesn't
|
||||
@@ -1578,8 +1634,8 @@ class OAuthManager:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
# Extract the "sub" claim, using custom claim if configured
|
||||
if auth_manager_config.OAUTH_SUB_CLAIM:
|
||||
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
|
||||
if auth_config.OAUTH_SUB_CLAIM:
|
||||
sub = user_data.get(auth_config.OAUTH_SUB_CLAIM)
|
||||
else:
|
||||
# Fallback to the default sub claim if not configured
|
||||
sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
|
||||
@@ -1593,7 +1649,7 @@ class OAuthManager:
|
||||
}
|
||||
|
||||
# Email extraction
|
||||
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
|
||||
email_claim = auth_config.OAUTH_EMAIL_CLAIM
|
||||
email = user_data.get(email_claim, '')
|
||||
# We currently mandate that email addresses are provided
|
||||
if not email:
|
||||
@@ -1635,8 +1691,8 @@ class OAuthManager:
|
||||
email = email.lower()
|
||||
# If allowed domains are configured, check if the email domain is in the list
|
||||
if (
|
||||
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
||||
'*' not in auth_config.OAUTH_ALLOWED_DOMAINS
|
||||
and email.split('@')[-1] not in auth_config.OAUTH_ALLOWED_DOMAINS
|
||||
):
|
||||
log.warning(f'OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
@@ -1645,7 +1701,7 @@ class OAuthManager:
|
||||
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
if not user:
|
||||
# If the user does not exist, check if merging is enabled
|
||||
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||
if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||
# Check if the user exists by email
|
||||
user = await Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
@@ -1660,8 +1716,8 @@ class OAuthManager:
|
||||
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
|
||||
user.role = determined_role
|
||||
|
||||
if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN:
|
||||
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
|
||||
if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN:
|
||||
username_claim = auth_config.OAUTH_USERNAME_CLAIM
|
||||
if username_claim:
|
||||
new_name = user_data.get(username_claim)
|
||||
if new_name and new_name != user.name:
|
||||
@@ -1669,8 +1725,8 @@ class OAuthManager:
|
||||
user.name = new_name
|
||||
log.debug(f'Updated name for user {user.email}')
|
||||
|
||||
if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
|
||||
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
|
||||
if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
|
||||
email_claim = auth_config.OAUTH_EMAIL_CLAIM
|
||||
if email_claim:
|
||||
new_email = user_data.get(email_claim)
|
||||
if new_email and new_email.lower() != user.email.lower():
|
||||
@@ -1685,8 +1741,8 @@ class OAuthManager:
|
||||
log.debug(f'Updated email for user {user.id}')
|
||||
|
||||
# Update profile picture if enabled and different from current
|
||||
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
new_picture_url = user_data.get(
|
||||
picture_claim,
|
||||
@@ -1700,13 +1756,13 @@ class OAuthManager:
|
||||
log.debug(f'Updated profile picture for user {user.email}')
|
||||
else:
|
||||
# If the user does not exist, check if signups are enabled
|
||||
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
|
||||
if auth_config.ENABLE_OAUTH_SIGNUP:
|
||||
# Check if an existing user with the same email already exists
|
||||
existing_user = await Users.get_user_by_email(email, db=db)
|
||||
if existing_user:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
picture_claim = auth_config.OAUTH_PICTURE_CLAIM
|
||||
if picture_claim:
|
||||
picture_url = user_data.get(
|
||||
picture_claim,
|
||||
@@ -1715,7 +1771,7 @@ class OAuthManager:
|
||||
picture_url = await self._process_picture_url(picture_url, token.get('access_token'))
|
||||
else:
|
||||
picture_url = '/user.png'
|
||||
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
|
||||
username_claim = auth_config.OAUTH_USERNAME_CLAIM
|
||||
|
||||
name = user_data.get(username_claim)
|
||||
if not name:
|
||||
@@ -1742,10 +1798,10 @@ class OAuthManager:
|
||||
await Users.update_user_role_by_id(user.id, 'admin', db=db)
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
|
||||
if auth_manager_config.WEBHOOK_URL:
|
||||
if auth_config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
WEBUI_NAME,
|
||||
auth_manager_config.WEBHOOK_URL,
|
||||
auth_config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
'action': 'signup',
|
||||
@@ -1754,7 +1810,8 @@ class OAuthManager:
|
||||
},
|
||||
)
|
||||
|
||||
await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db)
|
||||
default_group_id = await Config.get('ui.default_group_id')
|
||||
await apply_default_group_assignment(default_group_id, user.id, db=db)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -1764,13 +1821,13 @@ class OAuthManager:
|
||||
|
||||
jwt_token = create_token(
|
||||
data={'id': user.id},
|
||||
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
|
||||
expires_delta=parse_duration(auth_config.JWT_EXPIRES_IN),
|
||||
)
|
||||
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
|
||||
if auth_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
|
||||
await self.update_user_groups(
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
default_permissions=request.app.state.config.USER_PERMISSIONS,
|
||||
default_permissions=await Config.get('user.permissions'),
|
||||
db=db,
|
||||
)
|
||||
|
||||
@@ -1782,7 +1839,8 @@ class OAuthManager:
|
||||
else ERROR_MESSAGES.DEFAULT('Error during OAuth process')
|
||||
)
|
||||
|
||||
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
|
||||
webui_url = await Config.get('webui.url')
|
||||
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
|
||||
redirect_url = f'{redirect_base_url}/auth'
|
||||
|
||||
if error_message:
|
||||
@@ -1792,7 +1850,7 @@ class OAuthManager:
|
||||
response = RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
# Compute cookie expiry from JWT lifetime
|
||||
expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN)
|
||||
expires_delta = parse_duration(auth_config.JWT_EXPIRES_IN)
|
||||
cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None
|
||||
|
||||
# Set the cookie token
|
||||
|
||||
@@ -141,6 +141,9 @@ def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate')
|
||||
- start: keep first max_chars characters
|
||||
- end: keep last max_chars characters
|
||||
"""
|
||||
if max_chars <= 0:
|
||||
return ''
|
||||
|
||||
if not content or len(content) <= max_chars:
|
||||
return content
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from open_webui.env import (
|
||||
REDIS_KEY_PREFIX,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.tools import Tools
|
||||
from open_webui.models.users import UserModel
|
||||
@@ -345,7 +346,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get('idx', 0)
|
||||
connections = request.app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
connections = await Config.get('tool_server.connections', [])
|
||||
if tool_server_idx >= len(connections):
|
||||
log.warning(
|
||||
f'Tool server index {tool_server_idx} out of range '
|
||||
@@ -451,6 +452,16 @@ async def get_builtin_tools(
|
||||
|
||||
# Helper to check user-level feature permission (admins always pass)
|
||||
user = extra_params.get('__user__', {})
|
||||
config = await Config.get_many(
|
||||
'rag.web.search.enable',
|
||||
'image_generation.enable',
|
||||
'images.edit.enable',
|
||||
'code_interpreter.enable',
|
||||
'notes.enable',
|
||||
'channels.enable',
|
||||
'automations.enable',
|
||||
'calendar.enable',
|
||||
)
|
||||
|
||||
async def has_user_permission(feature_key: str) -> bool:
|
||||
if user.get('role') == 'admin':
|
||||
@@ -458,7 +469,7 @@ async def get_builtin_tools(
|
||||
return await has_permission(
|
||||
user.get('id', ''),
|
||||
f'features.{feature_key}',
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
|
||||
# Time utilities - available for date calculations
|
||||
@@ -533,7 +544,7 @@ async def get_builtin_tools(
|
||||
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
|
||||
if (
|
||||
is_builtin_tool_enabled('web_search')
|
||||
and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False)
|
||||
and config.get('rag.web.search.enable')
|
||||
and get_model_capability('web_search')
|
||||
and features.get('web_search')
|
||||
and await has_user_permission('web_search')
|
||||
@@ -543,7 +554,7 @@ async def get_builtin_tools(
|
||||
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
|
||||
if (
|
||||
is_builtin_tool_enabled('image_generation')
|
||||
and getattr(request.app.state.config, 'ENABLE_IMAGE_GENERATION', False)
|
||||
and config.get('image_generation.enable')
|
||||
and get_model_capability('image_generation')
|
||||
and features.get('image_generation')
|
||||
and await has_user_permission('image_generation')
|
||||
@@ -551,7 +562,7 @@ async def get_builtin_tools(
|
||||
builtin_functions.append(generate_image)
|
||||
if (
|
||||
is_builtin_tool_enabled('image_generation')
|
||||
and getattr(request.app.state.config, 'ENABLE_IMAGE_EDIT', False)
|
||||
and config.get('images.edit.enable')
|
||||
and get_model_capability('image_generation')
|
||||
and features.get('image_generation')
|
||||
and await has_user_permission('image_generation')
|
||||
@@ -561,7 +572,7 @@ async def get_builtin_tools(
|
||||
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
|
||||
if (
|
||||
is_builtin_tool_enabled('code_interpreter')
|
||||
and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True)
|
||||
and config.get('code_interpreter.enable')
|
||||
and get_model_capability('code_interpreter')
|
||||
and features.get('code_interpreter')
|
||||
and await has_user_permission('code_interpreter')
|
||||
@@ -571,7 +582,7 @@ async def get_builtin_tools(
|
||||
# Notes tools - search, view, create, and update user's notes
|
||||
if (
|
||||
is_builtin_tool_enabled('notes')
|
||||
and getattr(request.app.state.config, 'ENABLE_NOTES', False)
|
||||
and config.get('notes.enable')
|
||||
and await has_user_permission('notes')
|
||||
):
|
||||
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
|
||||
@@ -579,7 +590,7 @@ async def get_builtin_tools(
|
||||
# Channels tools - search channels and messages
|
||||
if (
|
||||
is_builtin_tool_enabled('channels')
|
||||
and getattr(request.app.state.config, 'ENABLE_CHANNELS', False)
|
||||
and config.get('channels.enable')
|
||||
and await has_user_permission('channels')
|
||||
):
|
||||
builtin_functions.extend(
|
||||
@@ -602,7 +613,7 @@ async def get_builtin_tools(
|
||||
# Automation tools - create and manage scheduled automations from chat
|
||||
if (
|
||||
is_builtin_tool_enabled('automations')
|
||||
and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False)
|
||||
and config.get('automations.enable')
|
||||
and await has_user_permission('automations')
|
||||
):
|
||||
builtin_functions.extend(
|
||||
@@ -612,7 +623,7 @@ async def get_builtin_tools(
|
||||
# Calendar tools - search/create/update/delete events
|
||||
if (
|
||||
is_builtin_tool_enabled('calendar')
|
||||
and getattr(request.app.state.config, 'ENABLE_CALENDAR', False)
|
||||
and config.get('calendar.enable')
|
||||
and await has_user_permission('calendar')
|
||||
):
|
||||
builtin_functions.extend(
|
||||
@@ -958,7 +969,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
|
||||
async def set_tool_servers(request: Request):
|
||||
try:
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS)
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(await Config.get('tool_server.connections', []))
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching tool server data: {e}')
|
||||
request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or []
|
||||
@@ -1055,7 +1066,7 @@ async def get_terminal_system_prompt(
|
||||
|
||||
async def set_terminal_servers(request: Request):
|
||||
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
|
||||
# Build server configs compatible with get_tool_servers_data
|
||||
# Terminal connections store id/name at top level; translate to info dict
|
||||
@@ -1148,7 +1159,7 @@ async def get_terminal_tools(
|
||||
- Loads specs from cache
|
||||
- Builds callables that route through the terminal proxy
|
||||
"""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == terminal_id), None)
|
||||
if connection is None:
|
||||
log.warning(f'Terminal server not found: {terminal_id}')
|
||||
|
||||
+7
-2
@@ -30,6 +30,7 @@ fi
|
||||
# ── Secret key setup ─────────────────────────────────────────────────────────
|
||||
|
||||
KEY_FILE="${WEBUI_SECRET_KEY_FILE:-.webui_secret_key}"
|
||||
WEBUI_SECRET_KEY_LENGTH="${WEBUI_SECRET_KEY_LENGTH:-24}"
|
||||
PORT="${PORT:-8080}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
|
||||
@@ -38,7 +39,11 @@ if [[ -z "${WEBUI_SECRET_KEY:-}" && -z "${WEBUI_JWT_SECRET_KEY:-}" ]]; then
|
||||
|
||||
if [[ ! -f "$KEY_FILE" ]]; then
|
||||
echo "Generating new WEBUI_SECRET_KEY..."
|
||||
head -c 12 /dev/random | base64 > "$KEY_FILE"
|
||||
if ! [[ "$WEBUI_SECRET_KEY_LENGTH" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "WEBUI_SECRET_KEY_LENGTH must be a positive integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
head -c "$WEBUI_SECRET_KEY_LENGTH" /dev/random | base64 > "$KEY_FILE"
|
||||
fi
|
||||
|
||||
echo "Loading WEBUI_SECRET_KEY from ${KEY_FILE}"
|
||||
@@ -105,4 +110,4 @@ exec env WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-}" \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
|
||||
"${ARGS[@]}"
|
||||
"${ARGS[@]}"
|
||||
|
||||
@@ -27,6 +27,9 @@ IF "%HOST%"=="" SET HOST=0.0.0.0
|
||||
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS='*'"
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
||||
IF "%WEBUI_SECRET_KEY_LENGTH%" == "" (
|
||||
SET "WEBUI_SECRET_KEY_LENGTH=24"
|
||||
)
|
||||
|
||||
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
|
||||
IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
||||
@@ -36,7 +39,7 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
||||
echo Generating WEBUI_SECRET_KEY
|
||||
:: Generate a random value to use as a WEBUI_SECRET_KEY in case the user didn't provide one
|
||||
SET /p WEBUI_SECRET_KEY=<nul
|
||||
FOR /L %%i IN (1,1,12) DO SET /p WEBUI_SECRET_KEY=<!random!>>%KEY_FILE%
|
||||
FOR /L %%i IN (1,1,%WEBUI_SECRET_KEY_LENGTH%) DO SET /p WEBUI_SECRET_KEY=<!random!>>%KEY_FILE%
|
||||
echo WEBUI_SECRET_KEY generated
|
||||
)
|
||||
|
||||
|
||||
@@ -254,6 +254,61 @@ export const updateLdapServer = async (token: string = '', body: object) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getOAuthConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateOAuthConfig = async (token: string, body: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const userSignIn = async (email: string, password: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -65,6 +65,38 @@ export const unarchiveAllChats = async (token: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const unshareAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/share/all`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const importChats = async (token: string, chats: object[]) => {
|
||||
let error = null;
|
||||
|
||||
@@ -255,6 +287,34 @@ export const getArchivedChatList = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getArchivedChatCount = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived/count`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSharedChatList = async (token: string = '', page: number = 1, filter?: object) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -214,6 +214,34 @@ export const searchFiles = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFileCount = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFileById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -234,3 +234,89 @@ export const deleteFolderById = async (token: string, id: string, deleteContents
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateFolderAccessById = async (
|
||||
token: string,
|
||||
id: string,
|
||||
accessGrants: any[]
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/access/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ access_grants: accessGrants })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSharedFolders = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/shared`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
return [];
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSharedFolderChats = async (token: string, folderId: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -85,6 +85,33 @@ export const updateUserDefaultPermissions = async (token: string, permissions: o
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getUserDefaultPermissionsDefaults = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions/defaults`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateUserRole = async (token: string, id: string, role: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
selectedTab = 'overview';
|
||||
chatList = [];
|
||||
allChatsLoaded = false;
|
||||
selectRange(selectedRange);
|
||||
selectedRange = '30d';
|
||||
loadOverview(30);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,12 +24,20 @@
|
||||
// Time period - persist in localStorage
|
||||
let selectedPeriod =
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
|
||||
|
||||
// Custom date range (YYYY-MM-DD) - persist in localStorage
|
||||
let customStart =
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomStart')) || '';
|
||||
let customEnd =
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomEnd')) || '';
|
||||
|
||||
$: periods = [
|
||||
{ value: '24h', label: $i18n.t('Last 24 hours') },
|
||||
{ value: '7d', label: $i18n.t('Last 7 days') },
|
||||
{ value: '30d', label: $i18n.t('Last 30 days') },
|
||||
{ value: '90d', label: $i18n.t('Last 90 days') },
|
||||
{ value: 'all', label: $i18n.t('All time') }
|
||||
{ value: 'all', label: $i18n.t('All time') },
|
||||
{ value: 'custom', label: $i18n.t('Custom range') }
|
||||
];
|
||||
|
||||
// User group filter
|
||||
@@ -48,6 +56,14 @@
|
||||
return { start: now - 30 * day, end: now };
|
||||
case '90d':
|
||||
return { start: now - 90 * day, end: now };
|
||||
case 'custom': {
|
||||
// Parse YYYY-MM-DD inputs; end date is inclusive (covers the full day)
|
||||
const start = customStart ? Math.floor(new Date(customStart).getTime() / 1000) : null;
|
||||
const end = customEnd
|
||||
? Math.floor(new Date(customEnd).getTime() / 1000) + day - 1
|
||||
: null;
|
||||
return { start, end };
|
||||
}
|
||||
default:
|
||||
return { start: null, end: null };
|
||||
}
|
||||
@@ -55,7 +71,13 @@
|
||||
|
||||
// Data
|
||||
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
|
||||
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
|
||||
let modelStats: Array<{
|
||||
model_id: string;
|
||||
count: number;
|
||||
unique_users?: number;
|
||||
unique_chats?: number;
|
||||
name?: string;
|
||||
}> = [];
|
||||
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
|
||||
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
||||
let tokenStats: Record<
|
||||
@@ -140,7 +162,13 @@
|
||||
loading = false;
|
||||
};
|
||||
|
||||
$: if (selectedPeriod || selectedGroupId !== undefined) {
|
||||
// Reload when the period, group, or custom range changes.
|
||||
// In custom mode, wait until both dates are set to avoid a half-specified query.
|
||||
$: if (selectedPeriod === 'custom' ? customStart && customEnd : selectedPeriod) {
|
||||
// reference customStart/customEnd so this block reruns when they change
|
||||
customStart;
|
||||
customEnd;
|
||||
selectedGroupId;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
@@ -163,6 +191,16 @@
|
||||
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
|
||||
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
|
||||
}
|
||||
if (modelOrderBy === 'users') {
|
||||
const aUsers = a.unique_users ?? 0;
|
||||
const bUsers = b.unique_users ?? 0;
|
||||
return modelDirection === 'asc' ? aUsers - bUsers : bUsers - aUsers;
|
||||
}
|
||||
if (modelOrderBy === 'chats') {
|
||||
const aChats = a.unique_chats ?? 0;
|
||||
const bChats = b.unique_chats ?? 0;
|
||||
return modelDirection === 'asc' ? aChats - bChats : bChats - aChats;
|
||||
}
|
||||
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
@@ -187,7 +225,11 @@
|
||||
localStorage.setItem('analyticsPeriod', selectedPeriod);
|
||||
}
|
||||
|
||||
onMount(loadDashboard);
|
||||
// Persist custom date range
|
||||
$: if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('analyticsCustomStart', customStart);
|
||||
localStorage.setItem('analyticsCustomEnd', customEnd);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header with title and period selector -->
|
||||
@@ -209,6 +251,21 @@
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if selectedPeriod === 'custom'}
|
||||
<input
|
||||
type="date"
|
||||
bind:value={customStart}
|
||||
max={customEnd || undefined}
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
|
||||
/>
|
||||
<span class="text-xs text-gray-400">–</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={customEnd}
|
||||
min={customStart || undefined}
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
|
||||
/>
|
||||
{/if}
|
||||
<select
|
||||
bind:value={selectedPeriod}
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
@@ -339,6 +396,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleModelSort('users')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Users')}
|
||||
{#if modelOrderBy === 'users'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp
|
||||
className="size-2"
|
||||
/>{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleModelSort('chats')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Chats')}
|
||||
{#if modelOrderBy === 'chats'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp
|
||||
className="size-2"
|
||||
/>{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
@@ -401,6 +494,8 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
|
||||
<td class="px-3 py-1 text-right">{(model.unique_users ?? 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-1 text-right">{(model.unique_chats ?? 0).toLocaleString()}</td>
|
||||
<td class="px-3 py-1 text-right"
|
||||
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
|
||||
>
|
||||
@@ -413,7 +508,7 @@
|
||||
{/each}
|
||||
{#if sortedModels.length === 0}
|
||||
<tr
|
||||
><td colspan="5" class="px-3 py-2 text-center text-gray-400"
|
||||
><td colspan="7" class="px-3 py-2 text-center text-gray-400"
|
||||
>{$i18n.t('No data')}</td
|
||||
></tr
|
||||
>
|
||||
|
||||
@@ -46,8 +46,9 @@
|
||||
try {
|
||||
const result = await getLeaderboard(localStorage.token, searchQuery);
|
||||
const statsMap = new Map((result?.entries ?? []).map((e) => [e.model_id, e]));
|
||||
const modelMap = new Map(($models ?? []).map((m) => [m.id, m]));
|
||||
|
||||
rankedModels = $models
|
||||
const activeModels = $models
|
||||
.filter((m) => m?.owned_by !== 'arena' && !m?.info?.meta?.hidden)
|
||||
.map((model) => {
|
||||
const s = statsMap.get(model.id);
|
||||
@@ -61,12 +62,27 @@
|
||||
},
|
||||
top_tags: s?.top_tags ?? []
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.rating === '-') return 1;
|
||||
if (b.rating === '-') return -1;
|
||||
return b.rating - a.rating;
|
||||
});
|
||||
|
||||
const evaluatedModels = (result?.entries ?? [])
|
||||
.filter((entry) => !modelMap.has(entry.model_id))
|
||||
.map((entry) => ({
|
||||
id: entry.model_id,
|
||||
name: entry.model_id,
|
||||
rating: entry.rating,
|
||||
stats: {
|
||||
count: entry.won + entry.lost,
|
||||
won: entry.won.toString(),
|
||||
lost: entry.lost.toString()
|
||||
},
|
||||
top_tags: entry.top_tags ?? []
|
||||
}));
|
||||
|
||||
rankedModels = [...activeModels, ...evaluatedModels].sort((a, b) => {
|
||||
if (a.rating === '-') return 1;
|
||||
if (b.rating === '-') return -1;
|
||||
return b.rating - a.rating;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Leaderboard load failed:', err);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
|
||||
// Load history when model changes and modal is shown
|
||||
$: if (show && model?.id) {
|
||||
selectRange(selectedRange);
|
||||
selectedRange = '30d';
|
||||
loadHistory(30);
|
||||
}
|
||||
|
||||
// Use top_tags from backend response (already computed)
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
let functions = null;
|
||||
let filteredItems = [];
|
||||
|
||||
$: if (query !== undefined) {
|
||||
const handleSearchInput = () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
setFilteredItems();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
|
||||
setFilteredItems();
|
||||
@@ -363,6 +363,7 @@
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
on:input={handleSearchInput}
|
||||
placeholder={$i18n.t('Search Functions')}
|
||||
/>
|
||||
|
||||
@@ -372,6 +373,7 @@
|
||||
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
query = '';
|
||||
handleSearchInput();
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
import Database from './Settings/Database.svelte';
|
||||
|
||||
import Authentication from './Settings/Authentication.svelte';
|
||||
import General from './Settings/General.svelte';
|
||||
import Pipelines from './Settings/Pipelines.svelte';
|
||||
import Audio from './Settings/Audio.svelte';
|
||||
@@ -37,6 +38,7 @@
|
||||
const tabFromPath = pathParts[pathParts.length - 1];
|
||||
selectedTab = [
|
||||
'general',
|
||||
'authentication',
|
||||
'connections',
|
||||
'models',
|
||||
'evaluations',
|
||||
@@ -94,6 +96,24 @@
|
||||
'channels'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'authentication',
|
||||
title: 'Authentication',
|
||||
route: '/admin/settings/authentication',
|
||||
keywords: [
|
||||
'authentication',
|
||||
'auth',
|
||||
'login',
|
||||
'signup',
|
||||
'ldap',
|
||||
'oauth',
|
||||
'oidc',
|
||||
'sso',
|
||||
'roles',
|
||||
'groups',
|
||||
'identity'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'connections',
|
||||
title: 'Connections',
|
||||
@@ -308,6 +328,7 @@
|
||||
</div>
|
||||
|
||||
<!-- {$i18n.t('General')} -->
|
||||
<!-- {$i18n.t('Authentication')} -->
|
||||
<!-- {$i18n.t('Connections')} -->
|
||||
<!-- {$i18n.t('Models')} -->
|
||||
<!-- {$i18n.t('Evaluations')} -->
|
||||
@@ -344,6 +365,19 @@
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'authentication'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 1.5A3.5 3.5 0 0 0 4.5 5v1H4a2 2 0 0 0-2 2v4.5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-.5V5A3.5 3.5 0 0 0 8 1.5ZM6 6V5a2 2 0 1 1 4 0v1H6Zm2 3a.75.75 0 0 0-.75.75v1a.75.75 0 0 0 1.5 0v-1A.75.75 0 0 0 8 9Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'connections'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -515,6 +549,8 @@
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'authentication'}
|
||||
<Authentication />
|
||||
{:else if selectedTab === 'connections'}
|
||||
<Connections
|
||||
on:save={() => {
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
<script lang="ts">
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
import {
|
||||
getAdminConfig,
|
||||
getLdapConfig,
|
||||
getLdapServer,
|
||||
getOAuthConfig,
|
||||
updateLdapConfig,
|
||||
updateLdapServer,
|
||||
updateOAuthConfig,
|
||||
updateAdminConfig
|
||||
} from '$lib/apis/auths';
|
||||
import { getGroups } from '$lib/apis/groups';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import { config } from '$lib/stores';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let adminConfig = null;
|
||||
let groups = [];
|
||||
|
||||
let ENABLE_LDAP = false;
|
||||
let LDAP_SERVER = {
|
||||
label: '',
|
||||
host: '',
|
||||
port: '',
|
||||
attribute_for_mail: 'mail',
|
||||
attribute_for_username: 'uid',
|
||||
app_dn: '',
|
||||
app_dn_password: '',
|
||||
search_base: '',
|
||||
search_filters: '',
|
||||
use_tls: false,
|
||||
certificate_path: '',
|
||||
ciphers: ''
|
||||
};
|
||||
|
||||
let oauthConfig: any = null;
|
||||
|
||||
const updateLdapServerHandler = async () => {
|
||||
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
|
||||
if (!ENABLE_LDAP) return true;
|
||||
|
||||
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
return !!res;
|
||||
};
|
||||
|
||||
const updateOAuthHandler = async () => {
|
||||
if (!oauthConfig) return true;
|
||||
const res = await updateOAuthConfig(localStorage.token, oauthConfig).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
oauthConfig = res;
|
||||
}
|
||||
return !!res;
|
||||
};
|
||||
|
||||
const updateAdminHandler = async () => {
|
||||
if (!adminConfig) return true;
|
||||
const res = await updateAdminConfig(localStorage.token, adminConfig).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
return !!res;
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
const adminSaved = await updateAdminHandler();
|
||||
const ldapSaved = await updateLdapServerHandler();
|
||||
const oauthSaved = await updateOAuthHandler();
|
||||
|
||||
if (adminSaved && ldapSaved && oauthSaved) {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
await config.set(await getBackendConfig());
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
adminConfig = await getAdminConfig(localStorage.token);
|
||||
})(),
|
||||
(async () => {
|
||||
groups = await getGroups(localStorage.token);
|
||||
})(),
|
||||
(async () => {
|
||||
LDAP_SERVER = await getLdapServer(localStorage.token);
|
||||
})(),
|
||||
(async () => {
|
||||
oauthConfig = await getOAuthConfig(localStorage.token).catch(() => null);
|
||||
})()
|
||||
]);
|
||||
|
||||
const ldapConfig = await getLdapConfig(localStorage.token);
|
||||
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between space-y-3 text-sm"
|
||||
on:submit|preventDefault={submitHandler}
|
||||
>
|
||||
<div class="space-y-3 overflow-y-scroll scrollbar-hidden h-full">
|
||||
{#if adminConfig !== null}
|
||||
<div class="mb-3">
|
||||
<div class="mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('User Access')}</div>
|
||||
|
||||
<hr class="border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={adminConfig.DEFAULT_USER_ROLE}
|
||||
placeholder={$i18n.t('Select a role')}
|
||||
>
|
||||
<option value="pending">{$i18n.t('pending')}</option>
|
||||
<option value="user">{$i18n.t('user')}</option>
|
||||
<option value="admin">{$i18n.t('admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={adminConfig.DEFAULT_GROUP_ID}
|
||||
placeholder={$i18n.t('Select a group')}
|
||||
>
|
||||
<option value={''}>None</option>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Keys')}</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_API_KEYS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig?.ENABLE_API_KEYS}
|
||||
<div class="mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('API Key Endpoint Restrictions')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS}
|
||||
<div class=" flex w-full flex-col pr-2 mb-2.5">
|
||||
<div class=" text-xs font-medium">
|
||||
{$i18n.t('Allowed Endpoints')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
|
||||
bind:value={adminConfig.API_KEYS_ALLOWED_ENDPOINTS}
|
||||
/>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<a
|
||||
href="https://docs.openwebui.com/reference/api-endpoints"
|
||||
target="_blank"
|
||||
class=" text-gray-300 font-medium underline"
|
||||
>
|
||||
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class=" mb-2.5 w-full justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 space-x-2">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={`e.g.) "30m","1h", "10d". `}
|
||||
bind:value={adminConfig.JWT_EXPIRES_IN}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('Valid time units:')}
|
||||
<span class=" text-gray-300 font-medium"
|
||||
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if adminConfig.JWT_EXPIRES_IN === '-1'}
|
||||
<div class="mt-2 text-xs">
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span class=" font-medium">{$i18n.t('Warning')}:</span>
|
||||
<span
|
||||
><a
|
||||
href="https://docs.openwebui.com/reference/env-configuration#jwt_expires_in"
|
||||
target="_blank"
|
||||
class=" underline"
|
||||
>{$i18n.t('No expiration can pose security risks.')}
|
||||
</a></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Pending Accounts')}</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Show Admin Details in Account Pending Overlay')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig.SHOW_ADMIN_DETAILS}
|
||||
<div class="mb-2.5 w-full justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Admin Contact Email')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 space-x-2">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="email"
|
||||
placeholder={$i18n.t('Leave empty to use first admin user')}
|
||||
bind:value={adminConfig.ADMIN_EMAIL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" self-center text-xs font-medium mb-2">
|
||||
{$i18n.t('Pending User Overlay Title')}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={$i18n.t(
|
||||
'Enter a title for the pending user info overlay. Leave empty for default.'
|
||||
)}
|
||||
bind:value={adminConfig.PENDING_USER_OVERLAY_TITLE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" self-center text-xs font-medium mb-2">
|
||||
{$i18n.t('Pending User Overlay Content')}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={$i18n.t(
|
||||
'Enter content for the pending user info overlay. Leave empty for default.'
|
||||
)}
|
||||
bind:value={adminConfig.PENDING_USER_OVERLAY_CONTENT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" space-y-3">
|
||||
<div class="mt-2 space-y-2 pr-1.5">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<div class=" font-medium">{$i18n.t('LDAP')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={ENABLE_LDAP} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ENABLE_LDAP}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Label')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Enter server label')}
|
||||
bind:value={LDAP_SERVER.label}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Host')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Enter server host')}
|
||||
bind:value={LDAP_SERVER.host}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Port')}
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top-start"
|
||||
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
|
||||
className="w-full"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
type="number"
|
||||
placeholder={$i18n.t('Enter server port')}
|
||||
bind:value={LDAP_SERVER.port}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Application DN')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t('The Application Account DN you bind with for search')}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter Application DN')}
|
||||
bind:value={LDAP_SERVER.app_dn}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Application DN Password')}
|
||||
</div>
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Application DN Password')}
|
||||
required={false}
|
||||
bind:value={LDAP_SERVER.app_dn_password}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Attribute for Mail')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The LDAP attribute that maps to the mail that users use to sign in.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: mail')}
|
||||
bind:value={LDAP_SERVER.attribute_for_mail}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Attribute for Username')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The LDAP attribute that maps to the username that users use to sign in.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: sAMAccountName or uid or userPrincipalName')}
|
||||
bind:value={LDAP_SERVER.attribute_for_username}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Search Base')}
|
||||
</div>
|
||||
<Tooltip content={$i18n.t('The base to search for users')} placement="top-start">
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
|
||||
bind:value={LDAP_SERVER.search_base}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Search Filters')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
|
||||
bind:value={LDAP_SERVER.search_filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
<a
|
||||
class=" text-gray-300 font-medium underline"
|
||||
href="https://ldap.com/ldap-filters/"
|
||||
target="_blank"
|
||||
>
|
||||
{$i18n.t('Click here for filter guides.')}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<div class=" font-medium">{$i18n.t('TLS')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={LDAP_SERVER.use_tls} />
|
||||
</div>
|
||||
</div>
|
||||
{#if LDAP_SERVER.use_tls}
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
|
||||
{$i18n.t('Certificate Path')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter certificate path')}
|
||||
bind:value={LDAP_SERVER.certificate_path}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<div class=" font-medium">{$i18n.t('Validate certificate')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={LDAP_SERVER.validate_cert} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Ciphers')}
|
||||
</div>
|
||||
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Example: ALL')}
|
||||
bind:value={LDAP_SERVER.ciphers}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if oauthConfig}
|
||||
<div class="mb-3">
|
||||
<div class="mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('OAuth / OIDC')}</div>
|
||||
|
||||
<hr class="border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class="pr-1.5">
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Provider Name')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="SSO"
|
||||
bind:value={oauthConfig.OAUTH_PROVIDER_NAME}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Provider URL')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="https://accounts.google.com/.well-known/openid-configuration"
|
||||
bind:value={oauthConfig.OPENID_PROVIDER_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Client ID')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter Client ID')}
|
||||
bind:value={oauthConfig.OAUTH_CLIENT_ID}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Client Secret')}
|
||||
</div>
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Client Secret')}
|
||||
required={false}
|
||||
outerClassName="flex flex-1 bg-transparent"
|
||||
inputClassName="w-full text-sm py-0.5 bg-transparent"
|
||||
bind:value={oauthConfig.OAUTH_CLIENT_SECRET}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Redirect URI')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter Redirect URI')}
|
||||
bind:value={oauthConfig.OPENID_REDIRECT_URI}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Scopes')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="openid email profile"
|
||||
bind:value={oauthConfig.OAUTH_SCOPES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Email Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="email"
|
||||
bind:value={oauthConfig.OAUTH_EMAIL_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Username Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="name"
|
||||
bind:value={oauthConfig.OAUTH_USERNAME_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Picture Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="picture"
|
||||
bind:value={oauthConfig.OAUTH_PICTURE_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Sub Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="sub"
|
||||
bind:value={oauthConfig.OAUTH_SUB_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Enable OAuth Signup')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.ENABLE_OAUTH_SIGNUP} />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Merge Accounts by Email')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.OAUTH_MERGE_ACCOUNTS_BY_EMAIL} />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Auto Redirect')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.OAUTH_AUTO_REDIRECT} />
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Allowed Domains')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="* (all domains)"
|
||||
bind:value={oauthConfig.OAUTH_ALLOWED_DOMAINS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Enable Role Mapping')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT} />
|
||||
</div>
|
||||
|
||||
{#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Roles Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="roles"
|
||||
bind:value={oauthConfig.OAUTH_ROLES_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Admin Roles')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="admin"
|
||||
bind:value={oauthConfig.OAUTH_ADMIN_ROLES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Allowed Roles')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="*"
|
||||
bind:value={oauthConfig.OAUTH_ALLOWED_ROLES}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Enable Group Mapping')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT} />
|
||||
</div>
|
||||
|
||||
{#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Auto-Create Groups')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.ENABLE_OAUTH_GROUP_CREATION} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Group Claim')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder="groups"
|
||||
bind:value={oauthConfig.OAUTH_GROUP_CLAIM}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Blocked Groups')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Comma-separated group names')}
|
||||
bind:value={oauthConfig.OAUTH_BLOCKED_GROUPS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Update Email')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.OAUTH_UPDATE_EMAIL_ON_LOGIN} />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Update Name')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.OAUTH_UPDATE_NAME_ON_LOGIN} />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-between pr-2">
|
||||
<div class="self-center text-xs font-medium">
|
||||
{$i18n.t('Update Picture')}
|
||||
</div>
|
||||
<Switch bind:state={oauthConfig.OAUTH_UPDATE_PICTURE_ON_LOGIN} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -41,7 +41,7 @@
|
||||
{#if config}
|
||||
<div>
|
||||
<div class="mb-3.5">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Code Execution')}</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
@@ -55,112 +55,117 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={config.CODE_EXECUTION_ENGINE}
|
||||
placeholder={$i18n.t('Select a engine')}
|
||||
required
|
||||
>
|
||||
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
|
||||
{#each engines as engine}
|
||||
<option value={engine}>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
|
||||
<div class="text-gray-500 text-xs">
|
||||
{$i18n.t(
|
||||
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
|
||||
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Jupyter URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter URL')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if config.ENABLE_CODE_EXECUTION}
|
||||
<div class="mb-2.5">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Code Execution Engine')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
|
||||
<div class=" flex gap-2 w-full items-center justify-between">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Jupyter Auth')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
|
||||
placeholder={$i18n.t('Select an auth method')}
|
||||
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={config.CODE_EXECUTION_ENGINE}
|
||||
placeholder={$i18n.t('Select a engine')}
|
||||
required
|
||||
>
|
||||
<option selected value="">{$i18n.t('None')}</option>
|
||||
<option value="token">{$i18n.t('Token')}</option>
|
||||
<option value="password">{$i18n.t('Password')}</option>
|
||||
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
|
||||
{#each engines as engine}
|
||||
<option value={engine}
|
||||
>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="flex-1">
|
||||
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
|
||||
<SensitiveInput
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter Password')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{:else}
|
||||
<SensitiveInput
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter Token')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
|
||||
<div class="text-gray-500 text-xs">
|
||||
{$i18n.t(
|
||||
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 w-full items-center justify-between">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Code Execution Timeout')}
|
||||
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
|
||||
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Jupyter URL')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter URL')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
|
||||
<input
|
||||
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
|
||||
type="number"
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
|
||||
placeholder={$i18n.t('e.g. 60')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
|
||||
<div class=" flex gap-2 w-full items-center justify-between">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Jupyter Auth')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
|
||||
placeholder={$i18n.t('Select an auth method')}
|
||||
>
|
||||
<option selected value="">{$i18n.t('None')}</option>
|
||||
<option value="token">{$i18n.t('Token')}</option>
|
||||
<option value="password">{$i18n.t('Password')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="flex-1">
|
||||
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
|
||||
<SensitiveInput
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter Password')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{:else}
|
||||
<SensitiveInput
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Jupyter Token')}
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 w-full items-center justify-between">
|
||||
<div class="text-xs font-medium">
|
||||
{$i18n.t('Code Execution Timeout')}
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
|
||||
<input
|
||||
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
|
||||
type="number"
|
||||
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
|
||||
placeholder={$i18n.t('e.g. 60')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,17 +3,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
|
||||
import {
|
||||
getAdminConfig,
|
||||
getLdapConfig,
|
||||
getLdapServer,
|
||||
updateAdminConfig,
|
||||
updateLdapConfig,
|
||||
updateLdapServer
|
||||
} from '$lib/apis/auths';
|
||||
import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths';
|
||||
import { getBanners, setBanners } from '$lib/apis/configs';
|
||||
import { getGroups } from '$lib/apis/groups';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
|
||||
@@ -29,35 +20,17 @@
|
||||
|
||||
export let saveHandler: Function;
|
||||
|
||||
let updateAvailable = null;
|
||||
let updateAvailable = false;
|
||||
let version = {
|
||||
current: '',
|
||||
latest: ''
|
||||
current: WEBUI_VERSION,
|
||||
latest: WEBUI_VERSION
|
||||
};
|
||||
|
||||
let adminConfig = null;
|
||||
let webhookUrl = '';
|
||||
let groups = [];
|
||||
|
||||
let banners: Banner[] = [];
|
||||
|
||||
// LDAP
|
||||
let ENABLE_LDAP = false;
|
||||
let LDAP_SERVER = {
|
||||
label: '',
|
||||
host: '',
|
||||
port: '',
|
||||
attribute_for_mail: 'mail',
|
||||
attribute_for_username: 'uid',
|
||||
app_dn: '',
|
||||
app_dn_password: '',
|
||||
search_base: '',
|
||||
search_filters: '',
|
||||
use_tls: false,
|
||||
certificate_path: '',
|
||||
ciphers: ''
|
||||
};
|
||||
|
||||
const checkForVersionUpdates = async () => {
|
||||
updateAvailable = null;
|
||||
version = await getVersionUpdates(localStorage.token).catch((error) => {
|
||||
@@ -73,17 +46,6 @@
|
||||
console.info(updateAvailable);
|
||||
};
|
||||
|
||||
const updateLdapServerHandler = async () => {
|
||||
if (!ENABLE_LDAP) return;
|
||||
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
toast.success($i18n.t('LDAP server updated'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateBanners = async () => {
|
||||
_banners.set(await setBanners(localStorage.token, banners));
|
||||
};
|
||||
@@ -91,8 +53,6 @@
|
||||
const updateHandler = async () => {
|
||||
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
|
||||
const res = await updateAdminConfig(localStorage.token, adminConfig);
|
||||
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
|
||||
await updateLdapServerHandler();
|
||||
|
||||
await updateBanners();
|
||||
|
||||
@@ -106,10 +66,6 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($config?.features?.enable_version_update_check) {
|
||||
checkForVersionUpdates();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
adminConfig = await getAdminConfig(localStorage.token);
|
||||
@@ -117,19 +73,10 @@
|
||||
|
||||
(async () => {
|
||||
webhookUrl = await getWebhookUrl(localStorage.token);
|
||||
})(),
|
||||
(async () => {
|
||||
LDAP_SERVER = await getLdapServer(localStorage.token);
|
||||
})(),
|
||||
(async () => {
|
||||
groups = await getGroups(localStorage.token);
|
||||
})()
|
||||
]);
|
||||
|
||||
const ldapConfig = await getLdapConfig(localStorage.token);
|
||||
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
|
||||
|
||||
banners = await getBanners(localStorage.token);
|
||||
banners = [...$_banners];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -300,395 +247,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Authentication')}</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={adminConfig.DEFAULT_USER_ROLE}
|
||||
placeholder={$i18n.t('Select a role')}
|
||||
>
|
||||
<option value="pending">{$i18n.t('pending')}</option>
|
||||
<option value="user">{$i18n.t('user')}</option>
|
||||
<option value="admin">{$i18n.t('admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
|
||||
bind:value={adminConfig.DEFAULT_GROUP_ID}
|
||||
placeholder={$i18n.t('Select a group')}
|
||||
>
|
||||
<option value={''}>None</option>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Show Admin Details in Account Pending Overlay')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig.SHOW_ADMIN_DETAILS}
|
||||
<div class="mb-2.5 w-full justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Admin Contact Email')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 space-x-2">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="email"
|
||||
placeholder={$i18n.t('Leave empty to use first admin user')}
|
||||
bind:value={adminConfig.ADMIN_EMAIL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" self-center text-xs font-medium mb-2">
|
||||
{$i18n.t('Pending User Overlay Title')}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={$i18n.t(
|
||||
'Enter a title for the pending user info overlay. Leave empty for default.'
|
||||
)}
|
||||
bind:value={adminConfig.PENDING_USER_OVERLAY_TITLE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5">
|
||||
<div class=" self-center text-xs font-medium mb-2">
|
||||
{$i18n.t('Pending User Overlay Content')}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={$i18n.t(
|
||||
'Enter content for the pending user info overlay. Leave empty for default.'
|
||||
)}
|
||||
bind:value={adminConfig.PENDING_USER_OVERLAY_CONTENT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Keys')}</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_API_KEYS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig?.ENABLE_API_KEYS}
|
||||
<div class="mb-2.5 flex w-full justify-between pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('API Key Endpoint Restrictions')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={adminConfig.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} />
|
||||
</div>
|
||||
|
||||
{#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS}
|
||||
<div class=" flex w-full flex-col pr-2 mb-2.5">
|
||||
<div class=" text-xs font-medium">
|
||||
{$i18n.t('Allowed Endpoints')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
|
||||
bind:value={adminConfig.API_KEYS_ALLOWED_ENDPOINTS}
|
||||
/>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<a
|
||||
href="https://docs.openwebui.com/reference/api-endpoints"
|
||||
target="_blank"
|
||||
class=" text-gray-300 font-medium underline"
|
||||
>
|
||||
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class=" mb-2.5 w-full justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 space-x-2">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={`e.g.) "30m","1h", "10d". `}
|
||||
bind:value={adminConfig.JWT_EXPIRES_IN}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('Valid time units:')}
|
||||
<span class=" text-gray-300 font-medium"
|
||||
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if adminConfig.JWT_EXPIRES_IN === '-1'}
|
||||
<div class="mt-2 text-xs">
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span class=" font-medium">{$i18n.t('Warning')}:</span>
|
||||
<span
|
||||
><a
|
||||
href="https://docs.openwebui.com/reference/env-configuration#jwt_expires_in"
|
||||
target="_blank"
|
||||
class=" underline"
|
||||
>{$i18n.t('No expiration can pose security risks.')}
|
||||
</a></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class=" space-y-3">
|
||||
<div class="mt-2 space-y-2 pr-1.5">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<div class=" font-medium">{$i18n.t('LDAP')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={ENABLE_LDAP} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ENABLE_LDAP}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Label')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Enter server label')}
|
||||
bind:value={LDAP_SERVER.label}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Host')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Enter server host')}
|
||||
bind:value={LDAP_SERVER.host}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Port')}
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top-start"
|
||||
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
|
||||
className="w-full"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
type="number"
|
||||
placeholder={$i18n.t('Enter server port')}
|
||||
bind:value={LDAP_SERVER.port}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Application DN')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t('The Application Account DN you bind with for search')}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter Application DN')}
|
||||
bind:value={LDAP_SERVER.app_dn}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Application DN Password')}
|
||||
</div>
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Application DN Password')}
|
||||
required={false}
|
||||
bind:value={LDAP_SERVER.app_dn_password}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Attribute for Mail')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The LDAP attribute that maps to the mail that users use to sign in.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: mail')}
|
||||
bind:value={LDAP_SERVER.attribute_for_mail}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Attribute for Username')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The LDAP attribute that maps to the username that users use to sign in.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t(
|
||||
'Example: sAMAccountName or uid or userPrincipalName'
|
||||
)}
|
||||
bind:value={LDAP_SERVER.attribute_for_username}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Search Base')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t('The base to search for users')}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
|
||||
bind:value={LDAP_SERVER.search_base}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Search Filters')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
|
||||
bind:value={LDAP_SERVER.search_filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
<a
|
||||
class=" text-gray-300 font-medium underline"
|
||||
href="https://ldap.com/ldap-filters/"
|
||||
target="_blank"
|
||||
>
|
||||
{$i18n.t('Click here for filter guides.')}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<div class=" font-medium">{$i18n.t('TLS')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={LDAP_SERVER.use_tls} />
|
||||
</div>
|
||||
</div>
|
||||
{#if LDAP_SERVER.use_tls}
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
|
||||
{$i18n.t('Certificate Path')}
|
||||
</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Enter certificate path')}
|
||||
bind:value={LDAP_SERVER.certificate_path}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<div class=" font-medium">{$i18n.t('Validate certificate')}</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Switch bind:state={LDAP_SERVER.validate_cert} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Ciphers')}
|
||||
</div>
|
||||
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden py-0.5"
|
||||
placeholder={$i18n.t('Example: ALL')}
|
||||
bind:value={LDAP_SERVER.ciphers}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Features')}</div>
|
||||
|
||||
|
||||
@@ -219,6 +219,10 @@
|
||||
config = res;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.ENABLE_IMAGE_GENERATION) {
|
||||
getModels();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
|
||||
ENABLE_AUTOCOMPLETE_GENERATION: true,
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
|
||||
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE: '',
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE: '',
|
||||
ENABLE_TAGS_GENERATION: true,
|
||||
ENABLE_SEARCH_QUERY_GENERATION: true,
|
||||
@@ -372,6 +373,21 @@
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="mb-2.5">
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Autocomplete Generation Prompt')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
|
||||
placement="top-start"
|
||||
>
|
||||
<Textarea
|
||||
bind:value={taskConfig.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE}
|
||||
placeholder={$i18n.t(
|
||||
'Leave empty to use the default prompt, or enter a custom prompt'
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.5">
|
||||
|
||||
@@ -221,13 +221,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EditGroupModal
|
||||
bind:show={showDefaultPermissionsModal}
|
||||
tabs={['permissions']}
|
||||
bind:permissions={defaultPermissions}
|
||||
custom={false}
|
||||
onSubmit={updateDefaultPermissionsHandler}
|
||||
/>
|
||||
{#if showDefaultPermissionsModal}
|
||||
<EditGroupModal
|
||||
bind:show={showDefaultPermissionsModal}
|
||||
tabs={['permissions']}
|
||||
permissions={defaultPermissions}
|
||||
custom={false}
|
||||
onSubmit={updateDefaultPermissionsHandler}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="flex items-center justify-between rounded-lg w-full transition mt-4"
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
import Users from './Users.svelte';
|
||||
import GroupPreviewPanel from './GroupPreviewPanel.svelte';
|
||||
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
|
||||
import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users';
|
||||
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
|
||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
@@ -31,6 +33,7 @@
|
||||
let selectedTab = 'general';
|
||||
let loading = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let showResetConfirmDialog = false;
|
||||
|
||||
let userCount = 0;
|
||||
|
||||
@@ -56,6 +59,29 @@
|
||||
show = false;
|
||||
};
|
||||
|
||||
const resetToDefaultsHandler = async () => {
|
||||
try {
|
||||
// For user groups: reset to current global default permissions
|
||||
// For default permissions modal: reset to stock/env-var configuration
|
||||
const defaults = custom
|
||||
? await getUserDefaultPermissions(localStorage.token)
|
||||
: await getUserDefaultPermissionsDefaults(localStorage.token);
|
||||
if (defaults) {
|
||||
permissions = {
|
||||
workspace: { ...DEFAULT_PERMISSIONS.workspace, ...defaults.workspace },
|
||||
sharing: { ...DEFAULT_PERMISSIONS.sharing, ...defaults.sharing },
|
||||
access_grants: { ...DEFAULT_PERMISSIONS.access_grants, ...defaults.access_grants },
|
||||
chat: { ...DEFAULT_PERMISSIONS.chat, ...defaults.chat },
|
||||
features: { ...DEFAULT_PERMISSIONS.features, ...defaults.features },
|
||||
settings: { ...DEFAULT_PERMISSIONS.settings, ...defaults.settings }
|
||||
};
|
||||
toast.success($i18n.t('Permissions reset to defaults'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
if (group) {
|
||||
name = group.name;
|
||||
@@ -94,6 +120,17 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showResetConfirmDialog}
|
||||
title={$i18n.t('Reset to Defaults')}
|
||||
message={$i18n.t(
|
||||
'Are you sure you want to reset all permissions to their default values? You will still need to save to apply the changes.'
|
||||
)}
|
||||
on:confirm={() => {
|
||||
resetToDefaultsHandler();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal size="lg" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
|
||||
@@ -250,7 +287,29 @@
|
||||
</div>
|
||||
|
||||
{#if ['general', 'permissions'].includes(selectedTab)}
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
<div class="flex justify-between items-center pt-3 text-sm font-medium gap-1.5">
|
||||
<div>
|
||||
{#if selectedTab === 'permissions'}
|
||||
<Tooltip
|
||||
content={custom
|
||||
? $i18n.t(
|
||||
'Reset group permissions to match the current default user permissions'
|
||||
)
|
||||
: $i18n.t('Reset all permissions to their initial configuration values')}
|
||||
>
|
||||
<button
|
||||
class="text-sm font-normal text-gray-500 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition hover:underline"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showResetConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Reset to Defaults')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
|
||||
@@ -393,6 +393,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Folders Sharing')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.sharing.folders} />
|
||||
</div>
|
||||
{#if defaultPermissions?.sharing?.folders && !permissions.sharing.folders}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
{#if permissions.chat.share}
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
@@ -683,6 +700,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Chat Import')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.chat['import']} />
|
||||
</div>
|
||||
{#if defaultPermissions?.chat?.import && !permissions.chat['import']}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
@@ -968,6 +1001,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('User Webhooks')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.features.webhooks} />
|
||||
</div>
|
||||
{#if defaultPermissions?.features?.webhooks && !permissions.features.webhooks}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
|
||||
@@ -84,13 +84,16 @@
|
||||
getUserList();
|
||||
}
|
||||
|
||||
$: if (query !== undefined) {
|
||||
const handleSearchInput = () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getUserList();
|
||||
if (page !== 1) {
|
||||
page = 1;
|
||||
} else {
|
||||
getUserList();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
@@ -106,6 +109,7 @@
|
||||
<input
|
||||
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
on:input={handleSearchInput}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -100,13 +100,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: if (query !== undefined) {
|
||||
const handleSearchInput = () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
page = 1;
|
||||
getUserList();
|
||||
if (page !== 1) {
|
||||
page = 1;
|
||||
} else {
|
||||
getUserList();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (page !== null && orderBy !== null && direction !== null) {
|
||||
getUserList();
|
||||
@@ -210,6 +213,7 @@
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
on:input={handleSearchInput}
|
||||
aria-label={$i18n.t('Search')}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
@@ -79,13 +78,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce only query changes
|
||||
$: if (query !== undefined && channel !== null) {
|
||||
const handleSearchInput = () => {
|
||||
if (channel === null) return;
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
getUserList();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// Immediate response to page/sort changes
|
||||
$: if (channel !== null && page && orderBy && direction) {
|
||||
@@ -143,6 +143,7 @@
|
||||
<input
|
||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
on:input={handleSearchInput}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,10 +117,6 @@
|
||||
.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
|
||||
];
|
||||
} else {
|
||||
if (userSuggestions) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
if (modelSuggestions) {
|
||||
_models = [
|
||||
...$models
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
{message}
|
||||
{channel}
|
||||
{thread}
|
||||
{id}
|
||||
replyToMessage={replyToMessage?.id === message.id}
|
||||
disabled={!channel?.write_access || message?.temp_id}
|
||||
pending={!!message?.temp_id}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
export let showUserProfile = true;
|
||||
export let thread = false;
|
||||
export let id = null;
|
||||
|
||||
export let replyToMessage = false;
|
||||
export let disabled = false;
|
||||
@@ -64,6 +65,12 @@
|
||||
let edit = false;
|
||||
let editedContent = null;
|
||||
let showDeleteConfirmDialog = false;
|
||||
$: renderedMessageId = message ? (id ? `${id}-${message.id}` : message.id) : null;
|
||||
$: replyToMessageId = message?.reply_to_message
|
||||
? id
|
||||
? `${id}-${message.reply_to_message.id}`
|
||||
: message.reply_to_message.id
|
||||
: null;
|
||||
|
||||
// Swipe-to-reply state
|
||||
let swipeStartX = 0;
|
||||
@@ -177,7 +184,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="message-{message.id}"
|
||||
id="message-{renderedMessageId}"
|
||||
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
|
||||
? className
|
||||
: `px-5 ${
|
||||
@@ -314,7 +321,7 @@
|
||||
class="ml-12 flex items-center space-x-2 relative z-0"
|
||||
on:click={() => {
|
||||
const messageElement = document.getElementById(
|
||||
`message-${message.reply_to_message.id}`
|
||||
`message-${replyToMessageId}`
|
||||
);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
@@ -354,7 +361,7 @@
|
||||
|
||||
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
|
||||
<Markdown
|
||||
id={`${message.id}-reply-to`}
|
||||
id={`${renderedMessageId}-reply-to`}
|
||||
content={message?.reply_to_message?.content}
|
||||
allowEmbeds={false}
|
||||
/>
|
||||
@@ -365,7 +372,7 @@
|
||||
|
||||
<div
|
||||
class=" flex w-full message-{message.id} "
|
||||
id="message-{message.id}"
|
||||
id="message-{renderedMessageId}"
|
||||
dir={$settings.chatDirection}
|
||||
>
|
||||
<div class={`shrink-0 mr-1 w-9`}>
|
||||
@@ -525,7 +532,7 @@
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<Markdown
|
||||
id={message.id}
|
||||
id={renderedMessageId}
|
||||
content={message.content}
|
||||
paragraphTag="span"
|
||||
allowEmbeds={!!message?.meta?.model_id}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
</button>
|
||||
</LinkPreview.Trigger>
|
||||
|
||||
<UserStatusLinkPreview id={user?.id} {side} {align} {sideOffset} />
|
||||
<UserStatusLinkPreview id={user?.id} {openPreview} {side} {align} {sideOffset} />
|
||||
</LinkPreview.Root>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
@@ -7,21 +7,35 @@
|
||||
|
||||
import UserStatus from './UserStatus.svelte';
|
||||
|
||||
export let id = null;
|
||||
export let id: string | null = null;
|
||||
export let openPreview = false;
|
||||
|
||||
export let side = 'top';
|
||||
export let align = 'start';
|
||||
export let sideOffset = 6;
|
||||
|
||||
let user = null;
|
||||
onMount(async () => {
|
||||
if (id) {
|
||||
user = await getUserInfoById(localStorage.token, id).catch((error) => {
|
||||
let requestedUserId: string | null = null;
|
||||
|
||||
const loadUser = async (userId: string) => {
|
||||
requestedUserId = userId;
|
||||
|
||||
const loadedUser = await getUserInfoById(localStorage.token, userId).catch((error) => {
|
||||
if (requestedUserId === userId) {
|
||||
console.error('Error fetching user by ID:', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (requestedUserId === userId) {
|
||||
user = loadedUser;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$: if (openPreview && id && id !== requestedUserId) {
|
||||
loadUser(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
|
||||
+129
-111
@@ -113,7 +113,6 @@
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Sidebar from '../icons/Sidebar.svelte';
|
||||
import Image from '../common/Image.svelte';
|
||||
import { getBanners } from '$lib/apis/configs';
|
||||
|
||||
export let chatIdProp = '';
|
||||
|
||||
@@ -169,6 +168,9 @@
|
||||
let chat = null;
|
||||
let tags = [];
|
||||
|
||||
// Read-only when viewing someone else's chat (e.g. via shared folder access)
|
||||
$: readOnly = chat != null && chat.user_id !== $user?.id;
|
||||
|
||||
let chatTasks = [];
|
||||
|
||||
let history = {
|
||||
@@ -327,107 +329,117 @@
|
||||
);
|
||||
};
|
||||
|
||||
let settingDefaults = false;
|
||||
const setDefaults = async () => {
|
||||
if (!$tools) {
|
||||
tools.set(await getTools(localStorage.token));
|
||||
}
|
||||
if (!$functions) {
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
}
|
||||
if (!$skills) {
|
||||
skills.set(await getSkills(localStorage.token));
|
||||
}
|
||||
if (selectedModels.length !== 1 && !atSelectedModel) {
|
||||
return;
|
||||
}
|
||||
if (settingDefaults) return;
|
||||
settingDefaults = true;
|
||||
|
||||
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
|
||||
if (model) {
|
||||
// Set Default Tools
|
||||
if (model?.info?.meta?.toolIds) {
|
||||
const defaultIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
try {
|
||||
if (!$tools) {
|
||||
tools.set(await getTools(localStorage.token));
|
||||
}
|
||||
if (!$functions) {
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
}
|
||||
if (!$skills) {
|
||||
skills.set(await getSkills(localStorage.token));
|
||||
}
|
||||
if (selectedModels.length !== 1 && !atSelectedModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate unauthenticated OAuth tools
|
||||
const unauthed = [];
|
||||
const authed = [];
|
||||
for (const id of defaultIds) {
|
||||
const tool = $tools.find((t) => t.id === id);
|
||||
if (tool && tool.authenticated === false) {
|
||||
const parts = id.split(':');
|
||||
const serverId = parts.at(-1) ?? id;
|
||||
const authType =
|
||||
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
|
||||
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
|
||||
} else {
|
||||
authed.push(id);
|
||||
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
|
||||
if (model) {
|
||||
// Set Default Tools
|
||||
if (model?.info?.meta?.toolIds) {
|
||||
const defaultIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) =>
|
||||
$tools.find((t) => t.id === id)
|
||||
)
|
||||
)
|
||||
];
|
||||
|
||||
// Separate unauthenticated OAuth tools
|
||||
const unauthed = [];
|
||||
const authed = [];
|
||||
for (const id of defaultIds) {
|
||||
const tool = $tools.find((t) => t.id === id);
|
||||
if (tool && tool.authenticated === false) {
|
||||
const parts = id.split(':');
|
||||
const serverId = parts.at(-1) ?? id;
|
||||
const authType =
|
||||
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
|
||||
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
|
||||
} else {
|
||||
authed.push(id);
|
||||
}
|
||||
}
|
||||
selectedToolIds = authed;
|
||||
pendingOAuthTools = unauthed;
|
||||
} else if ($settings?.tools) {
|
||||
selectedToolIds = $settings.tools;
|
||||
} else {
|
||||
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
|
||||
}
|
||||
|
||||
// Set Default Skills
|
||||
if (model?.info?.meta?.skillIds) {
|
||||
selectedSkillIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
|
||||
($skills ?? []).find((s) => s.id === id && s.is_active)
|
||||
)
|
||||
)
|
||||
];
|
||||
} else {
|
||||
selectedSkillIds = [];
|
||||
}
|
||||
|
||||
// Set Default Filters (Toggleable only)
|
||||
if (model?.info?.meta?.defaultFilterIds) {
|
||||
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
|
||||
model?.filters?.find((f) => f.id === id)
|
||||
);
|
||||
}
|
||||
|
||||
// Set Default Features
|
||||
if (model?.info?.meta?.defaultFeatureIds) {
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['image_generation'] &&
|
||||
$config?.features?.enable_image_generation &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
|
||||
) {
|
||||
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
|
||||
}
|
||||
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['web_search'] &&
|
||||
$config?.features?.enable_web_search &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
|
||||
) {
|
||||
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
|
||||
}
|
||||
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['code_interpreter'] &&
|
||||
$config?.features?.enable_code_interpreter &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
|
||||
) {
|
||||
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
|
||||
}
|
||||
}
|
||||
selectedToolIds = authed;
|
||||
pendingOAuthTools = unauthed;
|
||||
} else if ($settings?.tools) {
|
||||
selectedToolIds = $settings.tools;
|
||||
} else {
|
||||
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
|
||||
}
|
||||
|
||||
// Set Default Skills
|
||||
if (model?.info?.meta?.skillIds) {
|
||||
selectedSkillIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
|
||||
($skills ?? []).find((s) => s.id === id && s.is_active)
|
||||
)
|
||||
)
|
||||
];
|
||||
} else {
|
||||
selectedSkillIds = [];
|
||||
}
|
||||
|
||||
// Set Default Filters (Toggleable only)
|
||||
if (model?.info?.meta?.defaultFilterIds) {
|
||||
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
|
||||
model?.filters?.find((f) => f.id === id)
|
||||
);
|
||||
}
|
||||
|
||||
// Set Default Features
|
||||
if (model?.info?.meta?.defaultFeatureIds) {
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['image_generation'] &&
|
||||
$config?.features?.enable_image_generation &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
|
||||
) {
|
||||
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
|
||||
}
|
||||
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['web_search'] &&
|
||||
$config?.features?.enable_web_search &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
|
||||
) {
|
||||
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
|
||||
}
|
||||
|
||||
if (
|
||||
model.info?.meta?.capabilities?.['code_interpreter'] &&
|
||||
$config?.features?.enable_code_interpreter &&
|
||||
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
|
||||
) {
|
||||
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
|
||||
}
|
||||
}
|
||||
|
||||
// Set Default Terminal — only if the referenced terminal actually exists
|
||||
if (model?.info?.meta?.terminalId) {
|
||||
const tid = model.info.meta.terminalId;
|
||||
if (isTerminalAvailable(tid)) {
|
||||
selectedTerminalId.set(tid);
|
||||
// Set Default Terminal — only if the referenced terminal actually exists
|
||||
if (model?.info?.meta?.terminalId) {
|
||||
const tid = model.info.meta.terminalId;
|
||||
if (isTerminalAvailable(tid)) {
|
||||
selectedTerminalId.set(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
settingDefaults = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -793,13 +805,6 @@
|
||||
if (p.url.pathname === '/') {
|
||||
await tick();
|
||||
initNewChat();
|
||||
|
||||
// Re-fetch banners on navigation to homepage so newly configured banners appear
|
||||
try {
|
||||
banners.set(await getBanners(localStorage.token).catch(() => []));
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh banners:', e);
|
||||
}
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
@@ -2099,8 +2104,9 @@
|
||||
: selectedModels;
|
||||
|
||||
// Create response messages for each selected model
|
||||
// Build message_ids map: {model_id: assistant_message_id}
|
||||
const messageIdsMap: Record<string, string> = {};
|
||||
// Build message_ids list: [{model_id, message_id}, ...]
|
||||
// Uses an array instead of a dict to support duplicate model IDs in side-by-side chat.
|
||||
const messageIdsList: Array<{ model_id: string; message_id: string }> = [];
|
||||
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
|
||||
const model = $models.filter((m) => m.id === modelId).at(0);
|
||||
|
||||
@@ -2132,7 +2138,7 @@
|
||||
}
|
||||
|
||||
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
|
||||
messageIdsMap[modelId] = responseMessageId;
|
||||
messageIdsList.push({ model_id: modelId, message_id: responseMessageId });
|
||||
}
|
||||
}
|
||||
history = history;
|
||||
@@ -2178,7 +2184,7 @@
|
||||
// Single request — backend fans out to all models
|
||||
const primaryModelId = selectedModelIds[0];
|
||||
const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0);
|
||||
const primaryResponseMessageId = messageIdsMap[primaryModelId];
|
||||
const primaryResponseMessageId = messageIdsList[0]?.message_id;
|
||||
|
||||
if (primaryModel && primaryResponseMessageId) {
|
||||
const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId);
|
||||
@@ -2194,7 +2200,7 @@
|
||||
primaryResponseMessageId,
|
||||
_chatId,
|
||||
{
|
||||
messageIdsMap: selectedModelIds.length > 1 ? messageIdsMap : undefined,
|
||||
messageIdsList: selectedModelIds.length > 1 ? messageIdsList : undefined,
|
||||
regenerationPrompt
|
||||
}
|
||||
);
|
||||
@@ -2238,7 +2244,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
if ($settings?.memory ?? false) {
|
||||
if ($settings?.memory ?? $config?.features?.enable_memories ?? false) {
|
||||
features = { ...features, memory: true };
|
||||
}
|
||||
|
||||
@@ -2263,11 +2269,11 @@
|
||||
responseMessageId,
|
||||
_chatId,
|
||||
{
|
||||
messageIdsMap,
|
||||
messageIdsList,
|
||||
regenerationPrompt,
|
||||
continueResponse = false
|
||||
}: {
|
||||
messageIdsMap?: Record<string, string>;
|
||||
messageIdsList?: Array<{ model_id: string; message_id: string }>;
|
||||
regenerationPrompt?: string | null;
|
||||
continueResponse?: boolean;
|
||||
} = {}
|
||||
@@ -2471,7 +2477,7 @@
|
||||
folder_id: $selectedFolder?.id ?? undefined,
|
||||
|
||||
id: responseMessageId,
|
||||
...(messageIdsMap ? { message_ids: messageIdsMap } : {}),
|
||||
...(messageIdsList ? { message_ids: messageIdsList } : {}),
|
||||
parent_id: userMessage?.parentId ?? null,
|
||||
user_message: userMessage,
|
||||
...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}),
|
||||
@@ -3027,6 +3033,7 @@
|
||||
<FilesOverlay show={dragged} />
|
||||
<Navbar
|
||||
bind:this={navbarElement}
|
||||
{readOnly}
|
||||
chat={{
|
||||
id: $chatId,
|
||||
chat: {
|
||||
@@ -3103,6 +3110,7 @@
|
||||
<Messages
|
||||
bind:this={messagesRef}
|
||||
chatId={$chatId}
|
||||
{readOnly}
|
||||
bind:history
|
||||
bind:autoScroll
|
||||
bind:prompt
|
||||
@@ -3126,7 +3134,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
|
||||
{#if readOnly}
|
||||
<div class="pb-6 z-10">
|
||||
<div
|
||||
class="text-xs text-gray-400 dark:text-gray-500 text-center"
|
||||
>
|
||||
{$i18n.t('Read only')}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
|
||||
<MessageInput
|
||||
bind:this={messageInput}
|
||||
{history}
|
||||
@@ -3208,6 +3225,7 @@
|
||||
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center h-full">
|
||||
<Placeholder
|
||||
|
||||
@@ -58,8 +58,6 @@
|
||||
import { deleteFileById } from '$lib/apis/files';
|
||||
import { getChatById } from '$lib/apis/chats';
|
||||
import { getSessionUser } from '$lib/apis/auths';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
import { getSkills } from '$lib/apis/skills';
|
||||
|
||||
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
||||
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
|
||||
@@ -1106,9 +1104,6 @@
|
||||
dropzoneElement.addEventListener('drop', onDrop, true);
|
||||
dropzoneElement.addEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
|
||||
tools.set(await getTools(localStorage.token));
|
||||
skills.set(await getSkills(localStorage.token));
|
||||
};
|
||||
initialize();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user