mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-25 05:54:29 +00:00
Merge branch 'dev' into a11y-upstream/dialog-roles
This commit is contained in:
commit
d790f2ae88
628 changed files with 80176 additions and 35908 deletions
|
|
@ -16,6 +16,9 @@ CORS_ALLOW_ORIGIN='*'
|
|||
# Set to false to keep memory tools enabled without adding memory context to the system context.
|
||||
ENABLE_MEMORY_SYSTEM_CONTEXT=true
|
||||
|
||||
# Set to false to disable workspace Tools and Functions.
|
||||
ENABLE_PLUGINS=true
|
||||
|
||||
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||
FORWARDED_ALLOW_IPS='*'
|
||||
|
||||
|
|
|
|||
12
.github/workflows/docker.yaml
vendored
12
.github/workflows/docker.yaml
vendored
|
|
@ -75,6 +75,17 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Prepare CI Dockerfile
|
||||
run: |
|
||||
awk '
|
||||
/^FROM --platform=\$BUILDPLATFORM node:/ {
|
||||
print
|
||||
print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\""
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' Dockerfile > "${RUNNER_TEMP}/Dockerfile"
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
|
@ -115,6 +126,7 @@ jobs:
|
|||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: ${{ runner.temp }}/Dockerfile
|
||||
push: true
|
||||
platforms: ${{ matrix.platform.arch }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
|||
2
.github/workflows/frontend.yaml
vendored
2
.github/workflows/frontend.yaml
vendored
|
|
@ -43,6 +43,8 @@ jobs:
|
|||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
# ── Vitest unit tests ────────────────────────────────────────────────────
|
||||
unit-tests:
|
||||
|
|
|
|||
43
.github/workflows/issue-label.yaml
vendored
Normal file
43
.github/workflows/issue-label.yaml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Issue Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label-bug-reports:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add "bug" label to unlabeled bug reports
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Web-form submissions already carry the label from the issue template
|
||||
if (issue.labels.some((label) => label.name === 'bug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
|
||||
const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title);
|
||||
|
||||
// API/CLI-created issues that reproduce the bug report form structure.
|
||||
// Only headings distinctive to the bug form (both are required fields there) —
|
||||
// generic headings like "Expected Behavior" also appear in freeform feature requests.
|
||||
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
|
||||
|
||||
if (bugLikeTitle || bugFormBody) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['bug']
|
||||
});
|
||||
}
|
||||
|
|
@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
|
|||
|
||||
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.
|
||||
- 💻 **Open WebUI Computer** ([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.
|
||||
|
||||
- 🔒 **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.
|
||||
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): 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 gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 🔄 **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1176,6 +1176,7 @@ WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'tru
|
|||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '')
|
||||
|
||||
SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '')
|
||||
OPENSERP_BASE_URL = os.getenv('OPENSERP_BASE_URL', 'http://localhost:7000')
|
||||
|
||||
SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all')
|
||||
|
||||
|
|
@ -1664,7 +1665,13 @@ if default_prompt_suggestions == []:
|
|||
|
||||
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
|
||||
|
||||
MODEL_ORDER_LIST = []
|
||||
try:
|
||||
model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading MODEL_ORDER_LIST: {e}')
|
||||
model_order_list = []
|
||||
|
||||
MODEL_ORDER_LIST = model_order_list
|
||||
|
||||
try:
|
||||
default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
|
||||
|
|
@ -1806,6 +1813,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
|
|||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
||||
)
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true'
|
||||
|
|
@ -1840,6 +1850,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
|
|||
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = (
|
||||
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
|
||||
|
||||
USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true'
|
||||
|
|
@ -1926,10 +1940,12 @@ DEFAULT_USER_PERMISSIONS = {
|
|||
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING,
|
||||
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
|
||||
'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING,
|
||||
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
'access_grants': {
|
||||
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||
'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS,
|
||||
},
|
||||
'chat': {
|
||||
'controls': USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
|
|
@ -1987,6 +2003,14 @@ ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
|
|||
|
||||
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
|
||||
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
|
||||
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
|
||||
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
|
||||
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
|
||||
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
|
||||
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
|
||||
|
||||
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
|
||||
|
||||
AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '')
|
||||
|
|
@ -2128,29 +2152,35 @@ ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lowe
|
|||
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
|
||||
|
||||
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
|
||||
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
|
||||
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min(
|
||||
50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40')))
|
||||
)
|
||||
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
|
||||
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
|
||||
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
|
||||
Generate a concise title summarizing the chat history.
|
||||
### Guidelines:
|
||||
- The title should clearly represent the main theme or subject of the conversation.
|
||||
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
||||
- Keep it short: 2-4 words is best.
|
||||
- Do not use emojis, quotation marks, or special formatting.
|
||||
- Write the title in the chat's primary language; default to English if multilingual.
|
||||
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
||||
- Prioritize accuracy over creativity.
|
||||
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
||||
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
||||
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
||||
### Output:
|
||||
JSON format: { "title": "your concise title here" }
|
||||
### Examples:
|
||||
- { "title": "📉 Stock Market Trends" },
|
||||
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
|
||||
- { "title": "Evolution of Music Streaming" },
|
||||
- { "title": "Remote Work Productivity Tips" },
|
||||
- { "title": "Artificial Intelligence in Healthcare" },
|
||||
- { "title": "🎮 Video Game Development Insights" }
|
||||
- { "title": "Stock Trends" },
|
||||
- { "title": "Chocolate Chip Cookies" },
|
||||
- { "title": "Music Streaming" },
|
||||
- { "title": "Remote Work" }
|
||||
### Chat History:
|
||||
<chat_history>
|
||||
{{MESSAGES:END:2}}
|
||||
|
|
@ -2400,6 +2430,11 @@ if JWT_EXPIRES_IN == '-1':
|
|||
# OAuth config
|
||||
####################################
|
||||
|
||||
# Master switch for OAuth/OIDC sign-in. Defaults to enabled so existing
|
||||
# deployments that already have a provider configured keep working; admins can
|
||||
# turn it off to disable OAuth login without clearing their provider settings.
|
||||
ENABLE_OAUTH = os.getenv('ENABLE_OAUTH', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true'
|
||||
|
||||
OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true'
|
||||
|
|
@ -2553,6 +2588,23 @@ if _oauth_authorize_params:
|
|||
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
|
||||
def oauth_client_kwargs(scope: str, **kwargs):
|
||||
client_kwargs = {
|
||||
'scope': scope,
|
||||
**kwargs,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"' % ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
return client_kwargs
|
||||
|
||||
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
|
||||
|
|
@ -2563,10 +2615,7 @@ def load_oauth_providers():
|
|||
client_id=GOOGLE_CLIENT_ID,
|
||||
client_secret=GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': GOOGLE_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE),
|
||||
redirect_uri=GOOGLE_REDIRECT_URI,
|
||||
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
|
||||
)
|
||||
|
|
@ -2584,10 +2633,7 @@ def load_oauth_providers():
|
|||
client_id=MICROSOFT_CLIENT_ID,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET,
|
||||
server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}',
|
||||
client_kwargs={
|
||||
'scope': MICROSOFT_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE),
|
||||
redirect_uri=MICROSOFT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -2608,10 +2654,7 @@ def load_oauth_providers():
|
|||
authorize_url='https://github.com/login/oauth/authorize',
|
||||
api_base_url='https://api.github.com',
|
||||
userinfo_endpoint='https://api.github.com/user',
|
||||
client_kwargs={
|
||||
'scope': GITHUB_CLIENT_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE),
|
||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -2624,30 +2667,19 @@ def load_oauth_providers():
|
|||
if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL:
|
||||
|
||||
def oidc_oauth_register(oauth: OAuth):
|
||||
client_kwargs = {
|
||||
'scope': OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"'
|
||||
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
client = oauth.register(
|
||||
name='oidc',
|
||||
client_id=OAUTH_CLIENT_ID,
|
||||
client_secret=OAUTH_CLIENT_SECRET,
|
||||
server_metadata_url=OPENID_PROVIDER_URL,
|
||||
client_kwargs=client_kwargs,
|
||||
client_kwargs=oauth_client_kwargs(
|
||||
OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
),
|
||||
redirect_uri=OPENID_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -2875,6 +2907,7 @@ DEFAULT_CONFIG = {
|
|||
'web.search.trust_env': WEB_SEARCH_TRUST_ENV,
|
||||
'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'web.search.searxng_query_url': SEARXNG_QUERY_URL,
|
||||
'web.search.openserp_base_url': OPENSERP_BASE_URL,
|
||||
'web.search.searxng_language': SEARXNG_LANGUAGE,
|
||||
'web.search.yacy_query_url': YACY_QUERY_URL,
|
||||
'web.search.yacy_username': YACY_USERNAME,
|
||||
|
|
@ -3016,6 +3049,13 @@ DEFAULT_CONFIG = {
|
|||
'channels.enable': ENABLE_CHANNELS,
|
||||
'calendar.enable': ENABLE_CALENDAR,
|
||||
'automations.enable': ENABLE_AUTOMATIONS,
|
||||
'subagents.enable': ENABLE_SUBAGENTS,
|
||||
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
|
||||
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
|
||||
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
|
||||
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
|
||||
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
|
||||
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
|
||||
'automations.max_count': AUTOMATION_MAX_COUNT,
|
||||
'automations.min_interval': AUTOMATION_MIN_INTERVAL,
|
||||
'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN,
|
||||
|
|
@ -3034,6 +3074,8 @@ DEFAULT_CONFIG = {
|
|||
'task.model.external': TASK_MODEL_EXTERNAL,
|
||||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
|
||||
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
|
||||
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
|
||||
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
|
|
@ -3055,6 +3097,7 @@ DEFAULT_CONFIG = {
|
|||
'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||
'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS,
|
||||
'auth.jwt_expiry': JWT_EXPIRES_IN,
|
||||
'oauth.enable': ENABLE_OAUTH,
|
||||
'oauth.enable_signup': ENABLE_OAUTH_SIGNUP,
|
||||
'oauth.auto_redirect': OAUTH_AUTO_REDIRECT,
|
||||
'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
|
||||
|
|
|
|||
|
|
@ -389,6 +389,12 @@ try:
|
|||
except ValueError:
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||
|
||||
REDIS_SOCKET_TIMEOUT = os.getenv('REDIS_SOCKET_TIMEOUT', '')
|
||||
try:
|
||||
REDIS_SOCKET_TIMEOUT = float(REDIS_SOCKET_TIMEOUT)
|
||||
except ValueError:
|
||||
REDIS_SOCKET_TIMEOUT = None
|
||||
|
||||
# Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in:
|
||||
# defaults to off so behavior is unchanged for existing deployments. When
|
||||
# enabled, the kernel sends TCP keepalive probes on idle connections so
|
||||
|
|
@ -565,6 +571,19 @@ try:
|
|||
except (ValueError, TypeError):
|
||||
AIOHTTP_CLIENT_TIMEOUT = 300
|
||||
|
||||
# Optional between-chunks idle cap for streaming aiohttp requests.
|
||||
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = os.getenv('AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT', '')
|
||||
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT == '':
|
||||
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
|
||||
else:
|
||||
try:
|
||||
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = int(AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT)
|
||||
except (ValueError, TypeError):
|
||||
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
|
||||
|
||||
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT is not None and AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT <= 0:
|
||||
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
|
||||
|
||||
|
||||
# SSL verification for general outbound requests (OpenAI, OAuth, etc.).
|
||||
# Accepts "True", "False", or a path to a CA bundle file.
|
||||
|
|
@ -594,6 +613,15 @@ try:
|
|||
except (ValueError, TypeError):
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
|
||||
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
|
||||
try:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
|
||||
except Exception:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
# SSL verification for tool server connections specifically.
|
||||
# Accepts "True", "False", or a path to a CA bundle file.
|
||||
|
|
@ -788,6 +816,13 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10')
|
|||
# Token Exchange Configuration
|
||||
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
|
||||
_oauth_token_exchange_rate_limit = (os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT') or '').strip()
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT = (
|
||||
int(_oauth_token_exchange_rate_limit)
|
||||
if _oauth_token_exchange_rate_limit and _oauth_token_exchange_rate_limit.lower() != 'none'
|
||||
else None
|
||||
)
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW = int(os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW', str(60 * 3)))
|
||||
|
||||
# Back-Channel Logout Configuration
|
||||
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
|
||||
|
|
@ -1038,6 +1073,8 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
|
|||
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||
####################################
|
||||
|
||||
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
|
||||
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
|
||||
)
|
||||
|
|
@ -1091,15 +1128,19 @@ except ValueError:
|
|||
MAX_BODY_LOG_SIZE = 2048
|
||||
|
||||
# Comma separated list for urls to exclude from audit
|
||||
AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
|
||||
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
|
||||
AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS]
|
||||
AUDIT_EXCLUDED_PATHS = [
|
||||
path
|
||||
for path in (
|
||||
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
|
||||
)
|
||||
if path
|
||||
]
|
||||
|
||||
# Comma separated list of urls to include in audit (whitelist mode)
|
||||
# When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored
|
||||
AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
|
||||
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
|
||||
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
|
||||
AUDIT_INCLUDED_PATHS = [
|
||||
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path
|
||||
]
|
||||
|
||||
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
|
||||
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import uuid
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import VERSION
|
||||
from open_webui.env import ENABLE_PLUGINS, VERSION
|
||||
from open_webui.models.config import Config
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
|
|
@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
|
|||
CHAT_CREATED: EventDefinition = EventDefinition(
|
||||
name='chat.created', description='A chat was created.', message='Chat created'
|
||||
)
|
||||
CHAT_FINISHED: EventDefinition = EventDefinition(
|
||||
name='chat.finished', description='A chat response finished.', message='Chat finished'
|
||||
)
|
||||
CHAT_FAILED: EventDefinition = EventDefinition(
|
||||
name='chat.failed', description='A chat response failed.', message='Chat failed'
|
||||
)
|
||||
CHAT_IMPORTED: EventDefinition = EventDefinition(
|
||||
name='chat.imported', description='A chat was imported.', message='Chat imported'
|
||||
)
|
||||
|
|
@ -258,6 +264,11 @@ class EventDefinitions(BaseModel):
|
|||
description='A channel member active state was updated.',
|
||||
message='Channel member active updated',
|
||||
)
|
||||
CHANNEL_MESSAGE: EventDefinition = EventDefinition(
|
||||
name='channel.message',
|
||||
description='A channel message was posted.',
|
||||
message='Channel message',
|
||||
)
|
||||
CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition(
|
||||
name='channel.webhook.created',
|
||||
description='A channel incoming webhook was created.',
|
||||
|
|
@ -472,6 +483,16 @@ class EventDefinitions(BaseModel):
|
|||
FUNCTION_DISABLED: EventDefinition = EventDefinition(
|
||||
name='function.disabled', description='A function was disabled.', message='Function disabled'
|
||||
)
|
||||
FUNCTION_ENABLE_STARTED: EventDefinition = EventDefinition(
|
||||
name='function.enable_started',
|
||||
description='A function is about to be enabled.',
|
||||
message='Function enable started',
|
||||
)
|
||||
FUNCTION_DISABLE_STARTED: EventDefinition = EventDefinition(
|
||||
name='function.disable_started',
|
||||
description='A function is about to be disabled.',
|
||||
message='Function disable started',
|
||||
)
|
||||
FUNCTION_VALVES_UPDATED: EventDefinition = EventDefinition(
|
||||
name='function.valves_updated', description='Function valves were updated.', message='Function valves updated'
|
||||
)
|
||||
|
|
@ -566,6 +587,11 @@ class EventDefinitions(BaseModel):
|
|||
description='A calendar event RSVP was updated.',
|
||||
message='Calendar Event rsvp updated',
|
||||
)
|
||||
CALENDAR_ALERT: EventDefinition = EventDefinition(
|
||||
name='calendar.alert',
|
||||
description='A calendar event alert was triggered.',
|
||||
message='Calendar alert',
|
||||
)
|
||||
AUTOMATION_CREATED: EventDefinition = EventDefinition(
|
||||
name='automation.created', description='An automation was created.', message='Automation created'
|
||||
)
|
||||
|
|
@ -622,6 +648,12 @@ class EventDefinitions(BaseModel):
|
|||
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
|
||||
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
|
||||
)
|
||||
NOTIFICATION_TEST: EventDefinition = EventDefinition(
|
||||
name='notification.test', description='A notification target test was sent.', message='Notification test'
|
||||
)
|
||||
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
|
||||
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
|
||||
)
|
||||
|
||||
|
||||
EVENTS = EventDefinitions()
|
||||
|
|
@ -629,6 +661,12 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
|
|||
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
|
||||
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
|
||||
EVENT_CATALOG_SET = set(EVENT_CATALOG)
|
||||
NOTIFICATION_EVENTS = (
|
||||
EVENTS.CHAT_FINISHED.name,
|
||||
EVENTS.CHAT_FAILED.name,
|
||||
EVENTS.CHANNEL_MESSAGE.name,
|
||||
EVENTS.CALENDAR_ALERT.name,
|
||||
)
|
||||
|
||||
|
||||
def get_event_catalog() -> list[dict[str, str]]:
|
||||
|
|
@ -1024,7 +1062,27 @@ class WebhookEventSink:
|
|||
schedule_webhook_dispatch(app, event)
|
||||
|
||||
|
||||
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
|
||||
def schedule_notification_dispatch(app: Any, event: Event) -> None:
|
||||
try:
|
||||
from open_webui.utils.notifications import dispatch_notification_event
|
||||
|
||||
asyncio.create_task(dispatch_notification_event(app, event))
|
||||
except RuntimeError:
|
||||
log.exception('Notification delivery could not be scheduled for %s', event.event)
|
||||
|
||||
|
||||
class NotificationEventSink:
|
||||
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
|
||||
if event.event in NOTIFICATION_EVENTS:
|
||||
schedule_notification_dispatch(app, event)
|
||||
|
||||
|
||||
async def dispatch_event_functions(
|
||||
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
|
||||
) -> None:
|
||||
if not ENABLE_PLUGINS:
|
||||
return
|
||||
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
|
|
@ -1033,6 +1091,12 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None =
|
|||
|
||||
try:
|
||||
event_functions = await Functions.get_functions_by_type('event', active_only=True)
|
||||
if extra_function_ids:
|
||||
extra_functions = await Functions.get_functions_by_ids(extra_function_ids)
|
||||
existing_ids = {function.id for function in event_functions}
|
||||
event_functions.extend(
|
||||
function for function in extra_functions if function.type == 'event' and function.id not in existing_ids
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Event functions could not be loaded for %s', event.event)
|
||||
return
|
||||
|
|
@ -1081,7 +1145,7 @@ class EventFunctionSink:
|
|||
schedule_event_function_dispatch(app, event, request)
|
||||
|
||||
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
|
||||
|
||||
|
||||
async def publish_event(
|
||||
|
|
@ -1147,6 +1211,20 @@ async def publish_model_provider_request_failed(
|
|||
else 'upstream_error'
|
||||
)
|
||||
|
||||
# Server-log only; the upstream error body is otherwise invisible to admins
|
||||
# (event sinks require an event function or webhook to be configured).
|
||||
log.log(
|
||||
logging.ERROR if status >= 500 else logging.WARNING,
|
||||
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
|
||||
provider,
|
||||
status,
|
||||
error_type,
|
||||
base_url,
|
||||
requested_model or '-',
|
||||
error_code or '-',
|
||||
error_text[:MAX_STRING_LENGTH] or '-',
|
||||
)
|
||||
|
||||
data = {
|
||||
'error_type': error_type,
|
||||
'status': status,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from starlette.responses import Response, StreamingResponse
|
|||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
|
|
@ -69,6 +69,9 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
|
|||
|
||||
|
||||
async def get_function_models(request):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||
pipe_models = []
|
||||
|
||||
|
|
@ -144,7 +147,10 @@ async def get_function_models(request):
|
|||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
||||
if models is None:
|
||||
models = {}
|
||||
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
|
|
@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
|
||||
return params
|
||||
|
||||
# Copy so the base-model substitution below doesn't leak into the caller's
|
||||
# payload, which the tool-call continuation re-submits. Mirrors the routers.
|
||||
form_data = {**form_data}
|
||||
|
||||
model_id = form_data.get('model')
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
|||
|
||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
||||
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
|
||||
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
|
||||
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
|
|
@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
pool_size=_sqlite_pool_size,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ from open_webui.env import (
|
|||
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
ENABLE_EASTER_EGGS,
|
||||
ENABLE_PLUGINS,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
# OAuth Back-Channel Logout
|
||||
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
|
||||
|
|
@ -153,6 +154,7 @@ from open_webui.routers import (
|
|||
knowledge,
|
||||
memories,
|
||||
models,
|
||||
notifications,
|
||||
notes,
|
||||
ollama,
|
||||
openai,
|
||||
|
|
@ -218,6 +220,14 @@ from open_webui.utils.chat import (
|
|||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
)
|
||||
from open_webui.utils.chat_id import (
|
||||
get_temporary_chat_session_id,
|
||||
is_saved_chat_id,
|
||||
is_temporary_chat_id,
|
||||
)
|
||||
from open_webui.utils.chat_variables import (
|
||||
normalize_chat_variables,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.middleware import (
|
||||
|
|
@ -226,6 +236,7 @@ from open_webui.utils.middleware import (
|
|||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.models import (
|
||||
check_model_access,
|
||||
get_all_base_models,
|
||||
|
|
@ -247,7 +258,7 @@ from open_webui.utils.oauth import (
|
|||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.session_pool import get_session
|
||||
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
|
||||
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
|
||||
|
||||
if SAFE_MODE:
|
||||
|
|
@ -258,6 +269,16 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def emit_chat_list_event(metadata: dict, chat_id: str):
|
||||
if not is_saved_chat_id(chat_id):
|
||||
return
|
||||
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id'))
|
||||
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}})
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
|
|
@ -319,8 +340,9 @@ async def lifespan(app: FastAPI):
|
|||
await migrate_legacy_webhook_config()
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
|
||||
|
||||
license_task = None
|
||||
if LICENSE_KEY:
|
||||
get_license_data(app, LICENSE_KEY)
|
||||
license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY))
|
||||
|
||||
# Create admin account from env vars if specified and no users exist
|
||||
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
|
||||
|
|
@ -408,6 +430,14 @@ async def lifespan(app: FastAPI):
|
|||
log.warning(f'Failed to initialize terminal servers at startup: {e}')
|
||||
|
||||
# Mark application as ready to accept traffic from a startup perspective.
|
||||
if license_task:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(license_task), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning('License data retrieval is still pending; continuing startup without it')
|
||||
except Exception as e:
|
||||
log.warning(f'License data retrieval failed during startup: {e}')
|
||||
|
||||
app.state.startup_complete = True
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system')
|
||||
|
||||
|
|
@ -697,6 +727,25 @@ app.state.speech_speaker_embeddings_dataset = None
|
|||
app.state.MODELS = MODELS
|
||||
|
||||
# Add the middleware to the app
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
# Added before CompressMiddleware so audit sits inside compression and
|
||||
# captures response bodies before they are compressed (last added runs
|
||||
# outermost).
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
|
||||
if ENABLE_COMPRESSION_MIDDLEWARE:
|
||||
app.add_middleware(CompressMiddleware)
|
||||
|
||||
|
|
@ -751,6 +800,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
|
|||
|
||||
|
||||
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
|
||||
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
|
||||
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
|
||||
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
|
||||
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
|
||||
|
|
@ -774,21 +824,6 @@ if ENABLE_SCIM:
|
|||
app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim'])
|
||||
|
||||
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
##################################
|
||||
#
|
||||
# Chat Endpoints
|
||||
|
|
@ -801,12 +836,20 @@ if audit_level != AuditLevel.NONE:
|
|||
async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
for model in all_models:
|
||||
# Filter out filter pipelines
|
||||
if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':
|
||||
continue
|
||||
# Filter out filter pipelines
|
||||
models = [
|
||||
model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')
|
||||
]
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
# Access-filter first so the per-model payload work below only runs for
|
||||
# models the caller can actually see.
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
for model in models:
|
||||
# Remove profile image URL to reduce payload size
|
||||
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
|
||||
model['info']['meta'].pop('profile_image_url', None)
|
||||
|
|
@ -820,13 +863,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
except Exception as e:
|
||||
log.debug(f'Error processing model tags: {e}')
|
||||
model['tags'] = []
|
||||
pass
|
||||
|
||||
models.append(model)
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
model_order_list = await Config.get('ui.model_order_list')
|
||||
if model_order_list:
|
||||
|
|
@ -839,11 +875,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
)
|
||||
)
|
||||
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
return {'data': models}
|
||||
|
||||
|
||||
|
|
@ -857,12 +892,6 @@ class ModelUnloadForm(BaseModel):
|
|||
model: str
|
||||
|
||||
|
||||
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
|
||||
if prefix_id and model_id.startswith(f'{prefix_id}.'):
|
||||
return model_id[len(f'{prefix_id}.') :]
|
||||
return model_id
|
||||
|
||||
|
||||
@app.post('/api/models/unload')
|
||||
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
|
||||
"""
|
||||
|
|
@ -1112,6 +1141,13 @@ async def chat_completion(
|
|||
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
chat_id = form_data.get('chat_id') or ''
|
||||
chat_variables = form_data.pop('chat_variables', None)
|
||||
if chat_variables is None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
|
||||
chat_variables = existing_chat.variables if existing_chat else {}
|
||||
|
||||
chat_variables = normalize_chat_variables(chat_variables)
|
||||
|
||||
# Drop tool_servers if caller lacks features.direct_tool_servers —
|
||||
# mirrors the storage-side strip in user/settings/update.
|
||||
|
|
@ -1130,6 +1166,7 @@ async def chat_completion(
|
|||
metadata = {
|
||||
'user_id': user.id,
|
||||
'user_agent': request.headers.get('user-agent', '') or '',
|
||||
'internal': getattr(request.state, 'internal', False) is True,
|
||||
'chat_id': form_data.pop('chat_id', None) or '',
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
|
|
@ -1142,6 +1179,7 @@ async def chat_completion(
|
|||
'files': form_data.get('files', None),
|
||||
'features': form_data.get('features', {}),
|
||||
'variables': form_data.get('variables', {}),
|
||||
'chat_variables': chat_variables,
|
||||
'model': model,
|
||||
'direct': model_item.get('direct', False),
|
||||
'params': {
|
||||
|
|
@ -1205,9 +1243,7 @@ async def chat_completion(
|
|||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
if not chat_id.startswith('local:') and not chat_id.startswith(
|
||||
'channel:'
|
||||
): # temporary/channel chats are not stored
|
||||
if is_saved_chat_id(chat_id):
|
||||
if is_new_chat:
|
||||
# Build the full history upfront with ALL assistant placeholders
|
||||
user_message = metadata.get('user_message') or {}
|
||||
|
|
@ -1224,7 +1260,7 @@ async def chat_completion(
|
|||
target_model_id = entry['model_id']
|
||||
assistant_message_id = entry['message_id']
|
||||
if assistant_message_id:
|
||||
history_messages[assistant_message_id] = {
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
|
|
@ -1234,6 +1270,11 @@ async def chat_completion(
|
|||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
# Preserve the side-by-side column index so duplicate
|
||||
# models don't collapse into one another on reload.
|
||||
if entry.get('modelIdx') is not None:
|
||||
assistant_message['modelIdx'] = entry['modelIdx']
|
||||
history_messages[assistant_message_id] = assistant_message
|
||||
|
||||
await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
|
|
@ -1256,6 +1297,7 @@ async def chat_completion(
|
|||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
variables=chat_variables,
|
||||
folder_id=metadata.get('folder_id'),
|
||||
),
|
||||
)
|
||||
|
|
@ -1266,6 +1308,7 @@ async def chat_completion(
|
|||
subject_id=chat_id,
|
||||
data={'title': 'New Chat'},
|
||||
)
|
||||
await emit_chat_list_event(metadata, chat_id)
|
||||
if user_message_id:
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -1358,7 +1401,9 @@ async def chat_completion(
|
|||
updated['files'] = chat_files
|
||||
if selected_chat_models:
|
||||
updated['models'] = selected_chat_models
|
||||
await Chats.update_chat_by_id(chat_id, updated)
|
||||
await Chats.update_chat_by_id(chat_id, updated, touch=False)
|
||||
|
||||
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
|
||||
|
||||
# Save user message to DB
|
||||
if user_message and user_message.get('id'):
|
||||
|
|
@ -1367,6 +1412,7 @@ async def chat_completion(
|
|||
user_message['id'],
|
||||
user_message,
|
||||
)
|
||||
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MESSAGE_CREATED,
|
||||
|
|
@ -1378,6 +1424,15 @@ async def chat_completion(
|
|||
'content_preview': user_message.get('content', '')[:300],
|
||||
},
|
||||
)
|
||||
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
|
||||
'internal'
|
||||
):
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(chat_id, 'chat.user_message', user.id)
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
|
||||
|
||||
# Link grandparent → user message (childrenIds)
|
||||
grandparent_id = user_message.get('parentId')
|
||||
|
|
@ -1432,19 +1487,24 @@ async def chat_completion(
|
|||
target_model_id = entry['model_id']
|
||||
assistant_message_id = entry['message_id']
|
||||
if assistant_message_id:
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
# Preserve the side-by-side column index so duplicate
|
||||
# models don't collapse into one another on reload.
|
||||
if entry.get('modelIdx') is not None:
|
||||
assistant_message['modelIdx'] = entry['modelIdx']
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
assistant_message_id,
|
||||
{
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
},
|
||||
assistant_message,
|
||||
)
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -1513,9 +1573,7 @@ async def chat_completion(
|
|||
if metadata.get('chat_id') and metadata.get('message_id'):
|
||||
# Update the chat message with the error
|
||||
try:
|
||||
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
|
||||
'chat_id', ''
|
||||
).startswith('channel:'):
|
||||
if is_saved_chat_id(metadata.get('chat_id')):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
|
|
@ -1583,15 +1641,55 @@ async def chat_completion(
|
|||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
try:
|
||||
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(
|
||||
chat_id, user.id
|
||||
)
|
||||
await asyncio.shield(
|
||||
event_emitter(
|
||||
{
|
||||
'type': 'chat:active',
|
||||
'data': {'active': False, 'folder_id': folder_id},
|
||||
}
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat_id = metadata.get('chat_id')
|
||||
if (
|
||||
chat_id
|
||||
and getattr(request.state, 'internal', False) is not True
|
||||
and not await has_active_tasks(request.app.state.redis, chat_id)
|
||||
):
|
||||
from open_webui.utils.subagents import process_pending_internal_messages
|
||||
|
||||
await process_pending_internal_messages(
|
||||
request,
|
||||
chat_id,
|
||||
user.id,
|
||||
{
|
||||
'model_id': metadata.get('model_id') or form_data.get('model'),
|
||||
'session_id': metadata.get('session_id'),
|
||||
'tool_ids': metadata.get('tool_ids') or [],
|
||||
'skill_ids': metadata.get('skill_ids') or [],
|
||||
'system_prompt': metadata.get('system_prompt'),
|
||||
'filter_ids': metadata.get('filter_ids') or [],
|
||||
'terminal_id': metadata.get('terminal_id'),
|
||||
'features': metadata.get('features') or {},
|
||||
'variables': metadata.get('variables') or {},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
|
||||
|
||||
# Fan out: one task per model
|
||||
if metadata.get('session_id') and metadata.get('chat_id'):
|
||||
task_ids = []
|
||||
subagent_results = []
|
||||
is_internal = getattr(request.state, 'internal', False) is True
|
||||
chat_id = metadata['chat_id']
|
||||
|
||||
for idx, entry in enumerate(message_ids):
|
||||
|
|
@ -1604,6 +1702,7 @@ async def chat_completion(
|
|||
per_model_metadata = {
|
||||
**metadata,
|
||||
'message_id': assistant_message_id,
|
||||
'task_id': str(uuid4()),
|
||||
}
|
||||
|
||||
# Per-model form_data: own model
|
||||
|
|
@ -1618,28 +1717,39 @@ async def chat_completion(
|
|||
|
||||
# Only the first model runs chat-level background tasks;
|
||||
# subsequent models only run follow-ups.
|
||||
process = process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
)
|
||||
if is_internal:
|
||||
subagent_results.append(await process)
|
||||
continue
|
||||
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v
|
||||
for k, v in (tasks or {}).items()
|
||||
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
),
|
||||
process,
|
||||
id=chat_id,
|
||||
task_id=per_model_metadata['task_id'],
|
||||
)
|
||||
per_model_metadata['task_id'] = task_id
|
||||
task_ids.append(task_id)
|
||||
|
||||
if is_internal:
|
||||
return {
|
||||
'status': True,
|
||||
'task_ids': [],
|
||||
'chat_id': chat_id,
|
||||
'results': subagent_results,
|
||||
}
|
||||
|
||||
# Emit chat:active=true
|
||||
if task_ids:
|
||||
event_emitter = await get_event_emitter(
|
||||
|
|
@ -1647,7 +1757,8 @@ async def chat_completion(
|
|||
update_db=False,
|
||||
)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id)
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
|
|
@ -1679,10 +1790,80 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion
|
|||
from open_webui.utils.anthropic import (
|
||||
convert_anthropic_to_openai_payload,
|
||||
convert_openai_to_anthropic_response,
|
||||
is_anthropic_messages_passthrough,
|
||||
openai_stream_to_anthropic_stream,
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/message/count_tokens')
|
||||
@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint
|
||||
async def count_message_tokens(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
|
||||
|
||||
|
||||
async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict:
|
||||
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages'
|
||||
response = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
|
||||
if 'text/event-stream' in response.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(response),
|
||||
status_code=response.status,
|
||||
headers=openai._clean_proxy_headers(response.headers),
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
await openai.publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
provider='openai-compatible',
|
||||
base_url=url,
|
||||
api_key=key,
|
||||
status=response.status,
|
||||
requested_model=requested_model,
|
||||
upstream_error=response_data,
|
||||
)
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(status_code=response.status, content=response_data)
|
||||
return Response(status_code=response.status, content=response_data)
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model)
|
||||
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
finally:
|
||||
if not streaming:
|
||||
await cleanup_response(response)
|
||||
|
||||
|
||||
@app.post('/api/message')
|
||||
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
|
||||
async def generate_messages(
|
||||
|
|
@ -1703,10 +1884,33 @@ async def generate_messages(
|
|||
Authentication: Supports both standard Authorization header and
|
||||
Anthropic's x-api-key header (via middleware translation).
|
||||
"""
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
requested_model = form_data.get('model', '')
|
||||
input_tokens = None
|
||||
try:
|
||||
input_tokens = await openai.count_anthropic_tokens(request, form_data, user)
|
||||
except Exception:
|
||||
# Counting must not turn a compatible generation request into an outage.
|
||||
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
|
||||
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data)
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
|
||||
passthrough_params = []
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await openai.get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
if model:
|
||||
url, _, api_config = await openai.get_openai_connection(model['urlIdx'])
|
||||
if is_anthropic_messages_passthrough(url, api_config):
|
||||
return await passthrough_anthropic_messages(request, form_data, user)
|
||||
passthrough_params = api_config.get('passthrough_params') or []
|
||||
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
|
|
@ -1715,7 +1919,9 @@ async def generate_messages(
|
|||
if isinstance(response, StreamingResponse):
|
||||
# Streaming response: wrap the generator to convert SSE format
|
||||
return StreamingResponse(
|
||||
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model),
|
||||
openai_stream_to_anthropic_stream(
|
||||
response.body_iterator, model=requested_model, input_tokens=input_tokens or 0
|
||||
),
|
||||
media_type='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
|
|
@ -1723,16 +1929,37 @@ async def generate_messages(
|
|||
},
|
||||
)
|
||||
elif isinstance(response, dict):
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model)
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens)
|
||||
else:
|
||||
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
|
||||
return response
|
||||
|
||||
|
||||
async def verify_chat_ownership(chat_id: str | None, user) -> None:
|
||||
"""Temporary chats are per-socket and unsaved, so they have no owner to check."""
|
||||
if not chat_id or is_temporary_chat_id(chat_id):
|
||||
return
|
||||
|
||||
# Channel messages need the membership and write-access gate that only /api/chat/completions has.
|
||||
if chat_id.startswith('channel:'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Channel chats are not supported on this endpoint',
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/chat/completed')
|
||||
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
"""Deprecated: outlet filters now run inline during chat completion.
|
||||
Kept for backward compatibility with external integrations."""
|
||||
await verify_chat_ownership(form_data.get('chat_id'), user)
|
||||
|
||||
try:
|
||||
model_item = form_data.pop('model_item', {})
|
||||
|
||||
|
|
@ -1750,6 +1977,8 @@ async def chat_completed(request: Request, form_data: dict, user=Depends(get_ver
|
|||
|
||||
@app.post('/api/chat/actions/{action_id}')
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)):
|
||||
await verify_chat_ownership(form_data.get('chat_id'), user)
|
||||
|
||||
try:
|
||||
model_item = form_data.pop('model_item', {})
|
||||
|
||||
|
|
@ -1781,8 +2010,8 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
|
|||
|
||||
@app.get('/api/tasks/chat/{chat_id:path}')
|
||||
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
socket_id = get_temporary_chat_session_id(chat_id)
|
||||
if socket_id:
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
return {'task_ids': []}
|
||||
|
|
@ -1799,8 +2028,8 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
|
|||
|
||||
@app.post('/api/tasks/chat/{chat_id:path}/stop')
|
||||
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
socket_id = get_temporary_chat_session_id(chat_id)
|
||||
if socket_id:
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
|
@ -1852,6 +2081,7 @@ async def get_app_config(request: Request):
|
|||
license_metadata = getattr(app.state, 'LICENSE_METADATA', None)
|
||||
user_count = await Users.get_num_users() if license_metadata else None
|
||||
config = await Config.get_many(
|
||||
'oauth.enable',
|
||||
'oauth.auto_redirect',
|
||||
'ldap.enable',
|
||||
'ui.enable_signup',
|
||||
|
|
@ -1865,6 +2095,7 @@ async def get_app_config(request: Request):
|
|||
'calendar.enable',
|
||||
'automations.enable',
|
||||
'notes.enable',
|
||||
'chat.context_compaction.enable',
|
||||
'web.search.enable',
|
||||
'web.search.confirmation.enable',
|
||||
'web.search.confirmation.content',
|
||||
|
|
@ -1905,7 +2136,13 @@ async def get_app_config(request: Request):
|
|||
'version': VERSION,
|
||||
'default_locale': str(DEFAULT_LOCALE),
|
||||
'oauth': {
|
||||
'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()},
|
||||
# Hide providers (and thus the login buttons / auto-redirect) when OAuth
|
||||
# is disabled, without clearing the admin's provider configuration.
|
||||
'providers': (
|
||||
{name: provider.get('name', name) for name, provider in OAUTH_PROVIDERS.items()}
|
||||
if config.get('oauth.enable', True)
|
||||
else {}
|
||||
),
|
||||
'auto_redirect': config.get('oauth.auto_redirect'),
|
||||
},
|
||||
'features': {
|
||||
|
|
@ -1927,12 +2164,14 @@ async def get_app_config(request: Request):
|
|||
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
'enable_easter_eggs': ENABLE_EASTER_EGGS,
|
||||
'enable_direct_connections': config.get('direct.enable'),
|
||||
'enable_plugins': ENABLE_PLUGINS,
|
||||
'enable_folders': config.get('folders.enable'),
|
||||
'folder_max_file_count': config.get('folders.max_file_count'),
|
||||
'enable_channels': config.get('channels.enable'),
|
||||
'enable_calendar': config.get('calendar.enable'),
|
||||
'enable_automations': config.get('automations.enable'),
|
||||
'enable_notes': config.get('notes.enable'),
|
||||
'enable_context_compaction': config.get('chat.context_compaction.enable'),
|
||||
'enable_web_search': config.get('web.search.enable'),
|
||||
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
|
||||
'web_search_confirmation_content': config.get('web.search.confirmation.content'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
|
|||
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from open_webui.models.chat_messages import ChatMessage # noqa: F401
|
||||
from open_webui.models.chats import Chat # noqa: F401
|
||||
from sqlalchemy import create_engine, engine_from_config, pool
|
||||
|
||||
alembic_config = alembic.context.config
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
"""Add memory (id, user_id) covering index
|
||||
|
||||
Revision ID: 55f1302ac17c
|
||||
Revises: b0018471bbbe
|
||||
Create Date: 2026-07-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '55f1302ac17c'
|
||||
down_revision: Union[str, None] = 'b0018471bbbe'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' not in indexes:
|
||||
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' in indexes:
|
||||
op.drop_index('ix_memory_id_user_id', table_name='memory')
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""add chat message meta
|
||||
|
||||
Revision ID: 856c5b02fb54
|
||||
Revises: 42e2978c7933
|
||||
Create Date: 2026-07-16 01:39:39.291935
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '856c5b02fb54'
|
||||
down_revision: Union[str, None] = '42e2978c7933'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat_message', 'meta')
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"""add automation folder id
|
||||
|
||||
Revision ID: 959eaac8f909
|
||||
Revises: 55f1302ac17c
|
||||
Create Date: 2026-07-26 19:19:31.345756
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '959eaac8f909'
|
||||
down_revision: str | None = '55f1302ac17c'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
|
||||
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {col['name'] for col in inspector.get_columns('automation')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('automation')}
|
||||
|
||||
if 'folder_id' not in columns:
|
||||
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
|
||||
|
||||
if 'ix_automation_user_folder' not in indexes:
|
||||
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.drop_index('ix_automation_user_folder', table_name='automation')
|
||||
op.drop_column('automation', 'folder_id')
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {col['name'] for col in inspector.get_columns('automation')}
|
||||
indexes = {index['name'] for index in inspector.get_indexes('automation')}
|
||||
|
||||
if 'ix_automation_user_folder' in indexes:
|
||||
op.drop_index('ix_automation_user_folder', table_name='automation')
|
||||
|
||||
if 'folder_id' in columns:
|
||||
op.drop_column('automation', 'folder_id')
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
"""add current_message_id to chat
|
||||
|
||||
Revision ID: 9a1b2c3d4e5f
|
||||
Revises: 856c5b02fb54
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '9a1b2c3d4e5f'
|
||||
down_revision: Union[str, None] = '856c5b02fb54'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 150
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
if 'current_message_id' not in columns:
|
||||
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
|
||||
|
||||
chat = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.String()),
|
||||
sa.column('chat', sa.Text()),
|
||||
sa.column('current_message_id', sa.Text()),
|
||||
)
|
||||
chat_message = sa.table(
|
||||
'chat_message',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('chat_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
has_chat_message = 'chat_message' in inspector.get_table_names()
|
||||
result = conn.execute(
|
||||
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
|
||||
yield_per=BATCH_SIZE,
|
||||
stream_results=True,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
rows = result.fetchmany(BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch_chat_ids: list[str] = []
|
||||
candidates_by_chat: dict[str, list[str]] = {}
|
||||
current_by_chat: dict[str, str | None] = {}
|
||||
json_messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
|
||||
for row in rows:
|
||||
values = row._mapping
|
||||
chat_id = values['id']
|
||||
prefix = f'{chat_id}-'
|
||||
batch_chat_ids.append(chat_id)
|
||||
current_by_chat[chat_id] = values['current_message_id']
|
||||
|
||||
chat_data = {}
|
||||
if isinstance(values['chat'], dict):
|
||||
chat_data = values['chat']
|
||||
elif isinstance(values['chat'], str):
|
||||
try:
|
||||
parsed = json.loads(values['chat'])
|
||||
chat_data = parsed if isinstance(parsed, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
|
||||
candidates_by_chat[chat_id] = []
|
||||
for candidate in (
|
||||
values['current_message_id'],
|
||||
history.get('currentId'),
|
||||
chat_data.get('currentId'),
|
||||
chat_data.get('branchPointMessageId'),
|
||||
):
|
||||
if not isinstance(candidate, str) or not candidate:
|
||||
continue
|
||||
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
|
||||
if candidate not in candidates_by_chat[chat_id]:
|
||||
candidates_by_chat[chat_id].append(candidate)
|
||||
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(chat_data.get('messages'), list):
|
||||
messages = {
|
||||
message['id']: message
|
||||
for message in chat_data['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
if messages:
|
||||
json_messages_by_chat[chat_id] = {
|
||||
message_id: {
|
||||
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
|
||||
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
|
||||
}
|
||||
for message_id, message in messages.items()
|
||||
}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
|
||||
if has_chat_message:
|
||||
candidate_ids = {
|
||||
f'{chat_id}-{candidate}'
|
||||
for chat_id, candidates in candidates_by_chat.items()
|
||||
for candidate in candidates
|
||||
}
|
||||
if candidate_ids:
|
||||
valid_by_chat: dict[str, set[str]] = {}
|
||||
for row in conn.execute(
|
||||
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
|
||||
chat_message.c.chat_id.in_(batch_chat_ids),
|
||||
chat_message.c.id.in_(candidate_ids),
|
||||
)
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if message_id:
|
||||
valid_by_chat.setdefault(chat_id, set()).add(message_id)
|
||||
for chat_id, candidates in candidates_by_chat.items():
|
||||
valid_ids = valid_by_chat.get(chat_id, set())
|
||||
for candidate in candidates:
|
||||
if candidate in valid_ids:
|
||||
resolved[chat_id] = candidate
|
||||
break
|
||||
|
||||
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
|
||||
messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
if unresolved_chat_ids:
|
||||
for row in conn.execute(
|
||||
sa.select(
|
||||
chat_message.c.chat_id,
|
||||
chat_message.c.id,
|
||||
chat_message.c.parent_id,
|
||||
chat_message.c.created_at,
|
||||
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if not message_id:
|
||||
continue
|
||||
parent_id = values['parent_id']
|
||||
if parent_id and parent_id.startswith(prefix):
|
||||
parent_id = parent_id[len(prefix) :]
|
||||
messages_by_chat.setdefault(chat_id, {})[message_id] = {
|
||||
'parent_id': parent_id,
|
||||
'created_at': values['created_at'] or 0,
|
||||
}
|
||||
|
||||
for chat_id, messages in messages_by_chat.items():
|
||||
parent_ids = {
|
||||
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
for chat_id in batch_chat_ids:
|
||||
if chat_id in resolved:
|
||||
continue
|
||||
|
||||
messages = json_messages_by_chat.get(chat_id, {})
|
||||
valid_candidate = next(
|
||||
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
|
||||
None,
|
||||
)
|
||||
if valid_candidate:
|
||||
resolved[chat_id] = valid_candidate
|
||||
elif messages:
|
||||
parent_ids = {
|
||||
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
updates = [
|
||||
{'chat_id': chat_id, 'current_message_id': message_id}
|
||||
for chat_id, message_id in resolved.items()
|
||||
if message_id and message_id != current_by_chat.get(chat_id)
|
||||
]
|
||||
if updates:
|
||||
conn.execute(
|
||||
sa.update(chat)
|
||||
.where(chat.c.id == sa.bindparam('update_chat_id'))
|
||||
.values(current_message_id=sa.bindparam('update_current_message_id')),
|
||||
[
|
||||
{
|
||||
'update_chat_id': row['chat_id'],
|
||||
'update_current_message_id': row['current_message_id'],
|
||||
}
|
||||
for row in updates
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'current_message_id')
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add user variables
|
||||
|
||||
Revision ID: b0018471bbbe
|
||||
Revises: c49178636c78
|
||||
Create Date: 2026-07-24 01:21:46.457057
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b0018471bbbe'
|
||||
down_revision: Union[str, None] = 'c49178636c78'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
||||
|
||||
if 'variables' not in columns:
|
||||
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'variables')
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add chat variables
|
||||
|
||||
Revision ID: c49178636c78
|
||||
Revises: 9a1b2c3d4e5f
|
||||
Create Date: 2026-07-23 23:33:45.497453
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c49178636c78'
|
||||
down_revision: Union[str, None] = '9a1b2c3d4e5f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'variables' not in columns:
|
||||
op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'variables')
|
||||
|
|
@ -11,6 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PRINCIPAL_TYPE_ANYONE = 'anyone'
|
||||
PRINCIPAL_TYPE_GROUP = 'group'
|
||||
PRINCIPAL_TYPE_USER = 'user'
|
||||
WILDCARD_PRINCIPAL_ID = '*'
|
||||
|
||||
|
||||
####################
|
||||
# AccessGrant DB Schema
|
||||
|
|
@ -23,7 +28,7 @@ class AccessGrant(Base):
|
|||
id = Column(Text, primary_key=True)
|
||||
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_id = Column(Text, nullable=False)
|
||||
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||
principal_type = Column(Text, nullable=False) # "user", "group", or "anyone"
|
||||
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
|
||||
permission = Column(Text, nullable=False) # "read" or "write"
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
|
@ -163,12 +168,14 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
|||
principal_id = grant.get('principal_id')
|
||||
permission = grant.get('permission')
|
||||
|
||||
if principal_type not in ('user', 'group'):
|
||||
if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE):
|
||||
continue
|
||||
if permission not in ('read', 'write'):
|
||||
continue
|
||||
if not isinstance(principal_id, str) or not principal_id:
|
||||
continue
|
||||
if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'):
|
||||
continue
|
||||
|
||||
key = (principal_type, principal_id, permission)
|
||||
deduped[key] = {
|
||||
|
|
@ -186,7 +193,11 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
|
|||
Returns True when a direct grant list includes wildcard public-read.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
|
||||
if (
|
||||
grant['principal_type'] == PRINCIPAL_TYPE_USER
|
||||
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
|
||||
and grant['permission'] == 'read'
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -196,7 +207,25 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
|
|||
Returns True when a direct grant list includes wildcard public-write.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
|
||||
if (
|
||||
grant['principal_type'] == PRINCIPAL_TYPE_USER
|
||||
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
|
||||
and grant['permission'] == 'write'
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool:
|
||||
"""
|
||||
Returns True when a direct grant list includes no-auth anyone-read.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if (
|
||||
grant['principal_type'] == PRINCIPAL_TYPE_ANYONE
|
||||
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
|
||||
and grant['permission'] == 'read'
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -206,7 +235,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
|
|||
Returns True when a direct grant list includes any non-wildcard user grant.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
|
||||
if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -223,12 +252,27 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
|
|||
for grant in access_grants
|
||||
if not (
|
||||
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
== 'user'
|
||||
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
|
||||
== PRINCIPAL_TYPE_USER
|
||||
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
|
||||
!= WILDCARD_PRINCIPAL_ID
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def strip_anyone_access_grants(access_grants: Optional[list]) -> list:
|
||||
"""
|
||||
Remove no-auth anyone grants from the list.
|
||||
"""
|
||||
if not access_grants:
|
||||
return []
|
||||
return [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
!= PRINCIPAL_TYPE_ANYONE
|
||||
]
|
||||
|
||||
|
||||
def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
"""
|
||||
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
|
||||
|
|
@ -316,7 +360,6 @@ class AccessGrantsTable:
|
|||
)
|
||||
db.add(grant)
|
||||
await db.commit()
|
||||
await db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
async def revoke_access(
|
||||
|
|
@ -494,6 +537,28 @@ class AccessGrantsTable:
|
|||
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
|
||||
return result_dict
|
||||
|
||||
async def has_anyone_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = 'read',
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
"""Check for a no-auth anyone:* grant. Callers must opt in explicitly."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(AccessGrant)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == resource_id,
|
||||
AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE,
|
||||
AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID,
|
||||
AccessGrant.permission == permission,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first() is not None
|
||||
|
||||
async def has_access(
|
||||
self,
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -133,9 +133,8 @@ class AuthsTable:
|
|||
oauth=oauth,
|
||||
db=session,
|
||||
)
|
||||
# persist both records and reload generated defaults
|
||||
# persist both records
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
return created_user if credential and created_user else None
|
||||
|
||||
async def authenticate_user(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class Automation(Base):
|
|||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, nullable=False)
|
||||
folder_id = Column(Text, nullable=True)
|
||||
name = Column(Text, nullable=False)
|
||||
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
|
@ -31,7 +32,10 @@ class Automation(Base):
|
|||
created_at = Column(BigInteger, nullable=False)
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
|
||||
__table_args__ = (
|
||||
Index('ix_automation_next_run', 'next_run_at'),
|
||||
Index('ix_automation_user_folder', 'user_id', 'folder_id'),
|
||||
)
|
||||
|
||||
|
||||
class AutomationRun(Base):
|
||||
|
|
@ -72,6 +76,7 @@ class AutomationModel(BaseModel):
|
|||
|
||||
id: str
|
||||
user_id: str
|
||||
folder_id: Optional[str] = None
|
||||
name: str
|
||||
data: dict
|
||||
meta: Optional[dict] = None
|
||||
|
|
@ -96,6 +101,7 @@ class AutomationRunModel(BaseModel):
|
|||
|
||||
class AutomationForm(BaseModel):
|
||||
name: str
|
||||
folder_id: Optional[str] = None
|
||||
data: AutomationData
|
||||
meta: Optional[dict] = None
|
||||
is_active: Optional[bool] = True
|
||||
|
|
@ -129,6 +135,7 @@ class AutomationTable:
|
|||
row = Automation(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
folder_id=form.folder_id,
|
||||
name=form.name,
|
||||
data=form.data.model_dump(),
|
||||
meta=form.meta,
|
||||
|
|
@ -139,7 +146,6 @@ class AutomationTable:
|
|||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
|
|
@ -165,6 +171,7 @@ class AutomationTable:
|
|||
user_id: str,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[AsyncSession] = None,
|
||||
|
|
@ -172,6 +179,9 @@ class AutomationTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Automation).filter_by(user_id=user_id)
|
||||
|
||||
if folder_id is not None:
|
||||
stmt = stmt.filter(Automation.folder_id == (folder_id or None))
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
# Search in name and prompt inside JSON data
|
||||
|
|
@ -217,6 +227,7 @@ class AutomationTable:
|
|||
if not row:
|
||||
return None
|
||||
row.name = form.name
|
||||
row.folder_id = form.folder_id
|
||||
row.data = form.data.model_dump()
|
||||
row.meta = form.meta
|
||||
if form.is_active is not None:
|
||||
|
|
@ -224,9 +235,25 @@ class AutomationTable:
|
|||
row.next_run_at = next_run_at
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def clear_folder_ids(
|
||||
self,
|
||||
user_id: str,
|
||||
folder_ids: list[str],
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> int:
|
||||
if not folder_ids:
|
||||
return 0
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
update(Automation)
|
||||
.where(Automation.user_id == user_id, Automation.folder_id.in_(folder_ids))
|
||||
.values(folder_id=None, updated_at=int(time.time_ns()))
|
||||
)
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
async def toggle(
|
||||
self,
|
||||
id: str,
|
||||
|
|
@ -241,7 +268,6 @@ class AutomationTable:
|
|||
row.next_run_at = next_run_at if row.is_active else None
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
|
|
@ -324,7 +350,6 @@ class AutomationRunTable:
|
|||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationRunModel.model_validate(row)
|
||||
|
||||
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:
|
||||
|
|
|
|||
|
|
@ -500,9 +500,12 @@ class CalendarEventTable:
|
|||
# Filter to requested calendars only
|
||||
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
|
||||
|
||||
# Also get event IDs where user is an attendee
|
||||
# Also get event IDs where the user is an attendee, excluding invites they declined
|
||||
attendee_event_ids_result = await db.execute(
|
||||
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
|
||||
select(CalendarEventAttendee.event_id).filter(
|
||||
CalendarEventAttendee.user_id == user_id,
|
||||
CalendarEventAttendee.status != 'declined',
|
||||
)
|
||||
)
|
||||
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
|
||||
|
||||
|
|
@ -764,22 +767,32 @@ class CalendarEventAttendeeTable:
|
|||
async def set_attendees(
|
||||
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
|
||||
) -> list[CalendarEventAttendeeModel]:
|
||||
"""Replace all attendees for an event.
|
||||
"""Replace all attendees for an event ({user_id, meta?} per dict).
|
||||
|
||||
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
|
||||
RSVP status is the attendee's alone to set (via update_rsvp): an existing
|
||||
attendee keeps their status, a newly added one starts 'pending'. A
|
||||
caller-supplied status is ignored so an organiser cannot set it for others.
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
existing_status = {
|
||||
row.user_id: row.status
|
||||
for row in (
|
||||
await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
).scalars()
|
||||
}
|
||||
|
||||
# Remove existing
|
||||
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
|
||||
now = int(time.time_ns())
|
||||
models = []
|
||||
for att in attendees:
|
||||
user_id = att['user_id']
|
||||
row = CalendarEventAttendee(
|
||||
id=str(uuid4()),
|
||||
event_id=event_id,
|
||||
user_id=att['user_id'],
|
||||
status=att.get('status', 'pending'),
|
||||
user_id=user_id,
|
||||
status=existing_status.get(user_id, 'pending'),
|
||||
meta=att.get('meta'),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
|
|
|||
|
|
@ -869,7 +869,6 @@ class ChannelTable:
|
|||
result = ChannelFile(**channel_file.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return ChannelFileModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy import select, delete, func, cast, Integer, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -47,6 +50,17 @@ def _normalize_timestamp(timestamp: int) -> float:
|
|||
return timestamp
|
||||
|
||||
|
||||
def _timezone(tz: Optional[str]) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz or 'UTC')
|
||||
except ZoneInfoNotFoundError:
|
||||
return ZoneInfo('UTC')
|
||||
|
||||
|
||||
def _date_key(timestamp: int, tz: ZoneInfo) -> str:
|
||||
return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def get_usage(data: dict) -> Optional[dict]:
|
||||
"""Extract and normalize usage from message data."""
|
||||
usage = data.get('usage') or (data.get('info') or {}).get('usage')
|
||||
|
|
@ -72,6 +86,40 @@ def _token_columns(dialect: str):
|
|||
)
|
||||
|
||||
|
||||
def _extract_tool_names(value: Any) -> list[str]:
|
||||
names: list[str] = []
|
||||
|
||||
def add(name: Any):
|
||||
if isinstance(name, str):
|
||||
cleaned = name.strip()
|
||||
if cleaned and len(cleaned) <= 128:
|
||||
names.append(cleaned)
|
||||
|
||||
def walk(item: Any):
|
||||
if isinstance(item, list):
|
||||
for child in item:
|
||||
walk(child)
|
||||
return
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
|
||||
item_type = str(item.get('type') or '')
|
||||
looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'}
|
||||
if looks_like_tool:
|
||||
add(item.get('name') or item.get('tool_name'))
|
||||
function = item.get('function')
|
||||
if isinstance(function, dict):
|
||||
add(function.get('name'))
|
||||
|
||||
for key in ('tool_calls', 'tools', 'output', 'meta'):
|
||||
if key in item:
|
||||
walk(item.get(key))
|
||||
|
||||
walk(value)
|
||||
return names
|
||||
|
||||
|
||||
####################
|
||||
# ChatMessage DB Schema
|
||||
####################
|
||||
|
|
@ -100,6 +148,7 @@ class ChatMessage(Base):
|
|||
files = Column(JSON, nullable=True)
|
||||
sources = Column(JSON, nullable=True)
|
||||
embeds = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
# Status
|
||||
done = Column(Boolean, default=True)
|
||||
|
|
@ -142,6 +191,7 @@ class ChatMessageModel(BaseModel):
|
|||
files: Optional[list] = None
|
||||
sources: Optional[list] = None
|
||||
embeds: Optional[list] = None
|
||||
meta: Optional[dict] = None
|
||||
done: bool = True
|
||||
status_history: Optional[list] = None
|
||||
error: Optional[dict | str] = None
|
||||
|
|
@ -192,6 +242,8 @@ class ChatMessageTable:
|
|||
existing.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
existing.embeds = data.get('embeds')
|
||||
if 'meta' in data:
|
||||
existing.meta = data.get('meta')
|
||||
if 'done' in data:
|
||||
existing.done = data.get('done', True)
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
|
|
@ -207,7 +259,6 @@ class ChatMessageTable:
|
|||
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
|
||||
existing.updated_at = now
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
else:
|
||||
# Insert new
|
||||
|
|
@ -225,6 +276,7 @@ class ChatMessageTable:
|
|||
files=data.get('files'),
|
||||
sources=data.get('sources'),
|
||||
embeds=data.get('embeds'),
|
||||
meta=data.get('meta'),
|
||||
done=data.get('done', True),
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
|
|
@ -235,7 +287,6 @@ class ChatMessageTable:
|
|||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
|
||||
|
|
@ -243,6 +294,21 @@ class ChatMessageTable:
|
|||
message = await db.get(ChatMessage, id)
|
||||
return ChatMessageModel.model_validate(message) if message else None
|
||||
|
||||
async def has_unfinished_assistant_by_chat_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(ChatMessage.id)
|
||||
.where(ChatMessage.chat_id == chat_id)
|
||||
.where(ChatMessage.role == 'assistant')
|
||||
.where(ChatMessage.done.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
|
|
@ -584,6 +650,233 @@ class ChatMessageTable:
|
|||
for row in result.all()
|
||||
}
|
||||
|
||||
async def get_user_usage_summary(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
include_active_days: bool = True,
|
||||
timezone: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict:
|
||||
async with get_async_db_context(db) as db:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
)
|
||||
token_stmt = select(
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.usage.isnot(None),
|
||||
)
|
||||
models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
)
|
||||
if start_date:
|
||||
messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role))
|
||||
message_counts = {row.role: row.count for row in messages_result.all()}
|
||||
|
||||
token_result = (await db.execute(token_stmt)).one()
|
||||
models_used = (await db.execute(models_stmt)).scalar() or 0
|
||||
|
||||
active_days = set()
|
||||
if include_active_days:
|
||||
tz = _timezone(timezone)
|
||||
day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id)
|
||||
if start_date:
|
||||
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
day_result = await db.execute(day_stmt)
|
||||
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
|
||||
|
||||
input_total = int(token_result.input_tokens or 0)
|
||||
output_total = int(token_result.output_tokens or 0)
|
||||
|
||||
return {
|
||||
'messages': sum(message_counts.values()),
|
||||
'user_messages': message_counts.get('user', 0),
|
||||
'assistant_messages': message_counts.get('assistant', 0),
|
||||
'input_tokens': input_total,
|
||||
'output_tokens': output_total,
|
||||
'total_tokens': input_total + output_total,
|
||||
'models_used': int(models_used),
|
||||
'active_days': len(active_days),
|
||||
}
|
||||
|
||||
async def get_user_first_message_created_at(
|
||||
self,
|
||||
user_id: str,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(func.min(ChatMessage.created_at)).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at.isnot(None),
|
||||
)
|
||||
)
|
||||
value = result.scalar()
|
||||
return int(value) if value else None
|
||||
|
||||
async def get_user_daily_usage(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
timezone: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
tz = _timezone(timezone)
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.created_at,
|
||||
ChatMessage.chat_id,
|
||||
ChatMessage.role,
|
||||
ChatMessage.model_id,
|
||||
ChatMessage.usage,
|
||||
input_tokens.label('input_tokens'),
|
||||
output_tokens.label('output_tokens'),
|
||||
).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
daily: dict[str, dict] = {}
|
||||
for row in result.all():
|
||||
date = _date_key(row.created_at, tz)
|
||||
entry = daily.setdefault(
|
||||
date,
|
||||
{
|
||||
'date': date,
|
||||
'messages': 0,
|
||||
'chat_ids': set(),
|
||||
'tokens': 0,
|
||||
'models': Counter(),
|
||||
},
|
||||
)
|
||||
entry['messages'] += 1
|
||||
entry['chat_ids'].add(row.chat_id)
|
||||
if row.role == 'assistant' and row.model_id:
|
||||
entry['models'][row.model_id] += 1
|
||||
if row.usage:
|
||||
entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0)
|
||||
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
while current <= end_dt:
|
||||
date = current.strftime('%Y-%m-%d')
|
||||
daily.setdefault(
|
||||
date,
|
||||
{'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()},
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
return [
|
||||
{
|
||||
'date': item['date'],
|
||||
'messages': item['messages'],
|
||||
'chats': len(item['chat_ids']),
|
||||
'tokens': item['tokens'],
|
||||
'models': dict(item['models']),
|
||||
}
|
||||
for item in sorted(daily.values(), key=lambda x: x['date'])
|
||||
]
|
||||
|
||||
async def get_user_top_models(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
limit: int = 5,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ChatMessage.model_id,
|
||||
func.count(ChatMessage.id).label('messages'),
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
)
|
||||
.filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
.group_by(ChatMessage.model_id)
|
||||
.order_by(func.count(ChatMessage.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [
|
||||
{
|
||||
'model_id': row.model_id,
|
||||
'messages': row.messages,
|
||||
'input_tokens': int(row.input_tokens or 0),
|
||||
'output_tokens': int(row.output_tokens or 0),
|
||||
'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0),
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
async def get_user_top_tools(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
limit: int = 5,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(ChatMessage.output, ChatMessage.meta).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
counts: Counter[str] = Counter()
|
||||
for output, meta in result.all():
|
||||
for name in _extract_tool_names(output):
|
||||
counts[name] += 1
|
||||
for name in _extract_tool_names(meta):
|
||||
counts[name] += 1
|
||||
|
||||
return [{'name': name, 'count': count} for name, count in counts.most_common(limit)]
|
||||
|
||||
async def get_message_count_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ from open_webui.models.automations import AutomationRun
|
|||
from open_webui.models.chat_messages import ChatMessage, ChatMessages
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.tags import Tag, TagModel, Tags
|
||||
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
|
|
@ -27,6 +27,7 @@ from sqlalchemy import (
|
|||
UniqueConstraint,
|
||||
and_,
|
||||
delete,
|
||||
exists,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
|
|
@ -35,10 +36,35 @@ from sqlalchemy import (
|
|||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.sql import exists
|
||||
from sqlalchemy.sql import case, exists
|
||||
from sqlalchemy.sql.expression import bindparam
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
|
||||
|
||||
|
||||
def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None):
|
||||
if sort_by != 'unread_updated_at':
|
||||
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
|
||||
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
|
||||
return order_clause, Chat.id
|
||||
|
||||
unfinished_assistant = (
|
||||
select(ChatMessage.id)
|
||||
.where(ChatMessage.chat_id == Chat.id)
|
||||
.where(ChatMessage.role == 'assistant')
|
||||
.where(ChatMessage.done.is_(False))
|
||||
.exists()
|
||||
)
|
||||
conditions = [Chat.updated_at > func.coalesce(Chat.last_read_at, 0), ~unfinished_assistant]
|
||||
if user_id is not None:
|
||||
conditions.append(Chat.user_id == user_id)
|
||||
|
||||
unread = case(
|
||||
(and_(*conditions), 1),
|
||||
else_=0,
|
||||
)
|
||||
return unread.desc(), Chat.updated_at.desc(), Chat.id
|
||||
|
||||
|
||||
class Chat(Base): # database table mapping for chat entity
|
||||
|
|
@ -57,10 +83,12 @@ class Chat(Base): # database table mapping for chat entity
|
|||
pinned = Column(Boolean, default=False, nullable=True)
|
||||
|
||||
meta = Column(JSON, server_default='{}')
|
||||
variables = Column(JSON, nullable=True)
|
||||
folder_id = Column(Text, nullable=True)
|
||||
|
||||
tasks = Column(JSON, nullable=True)
|
||||
summary = Column(Text, nullable=True)
|
||||
current_message_id = Column(Text, nullable=True)
|
||||
|
||||
last_read_at = Column(BigInteger, nullable=True)
|
||||
|
||||
|
|
@ -74,6 +102,10 @@ class Chat(Base): # database table mapping for chat entity
|
|||
)
|
||||
|
||||
|
||||
def is_internal_chat(meta: dict | None) -> bool:
|
||||
return bool(meta and meta.get('internal') is True)
|
||||
|
||||
|
||||
class ChatModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
|
||||
id: str
|
||||
|
|
@ -89,13 +121,20 @@ class ChatModel(BaseModel):
|
|||
pinned: bool | None = False
|
||||
|
||||
meta: dict = {}
|
||||
variables: dict = {}
|
||||
folder_id: str | None = None
|
||||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
|
||||
last_read_at: int | None = None
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ChatFile(Base):
|
||||
__tablename__ = 'chat_file'
|
||||
|
|
@ -134,12 +173,14 @@ class ChatFileModel(BaseModel):
|
|||
|
||||
class ChatForm(BaseModel):
|
||||
chat: dict
|
||||
variables: dict | None = None
|
||||
folder_id: str | None = None
|
||||
|
||||
|
||||
class ChatImportForm(ChatForm):
|
||||
meta: dict | None = {}
|
||||
pinned: bool | None = False
|
||||
current_message_id: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
|
@ -168,10 +209,18 @@ class ChatResponse(BaseModel):
|
|||
archived: bool
|
||||
pinned: bool | None = False
|
||||
meta: dict = {}
|
||||
variables: dict = {}
|
||||
folder_id: str | None = None
|
||||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
context_usage: dict | None = None
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ChatTitleIdResponse(BaseModel):
|
||||
|
|
@ -181,6 +230,7 @@ class ChatTitleIdResponse(BaseModel):
|
|||
created_at: int
|
||||
last_read_at: int | None = None
|
||||
snippet: str | None = None
|
||||
active: bool = False
|
||||
|
||||
|
||||
class SharedChatResponse(BaseModel):
|
||||
|
|
@ -273,6 +323,21 @@ class ChatTable:
|
|||
"""Recursively remove null bytes from strings in dict/list structures."""
|
||||
return sanitize_data_for_db(obj)
|
||||
|
||||
def get_current_message_id(self, chat: dict | None) -> str | None:
|
||||
chat = chat or {}
|
||||
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
|
||||
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
|
||||
if current_id:
|
||||
return current_id
|
||||
|
||||
messages = chat.get('messages')
|
||||
if isinstance(messages, list):
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('id'):
|
||||
return message['id']
|
||||
|
||||
return None
|
||||
|
||||
def _sanitize_chat_row(self, chat_item):
|
||||
"""
|
||||
Clean a Chat SQLAlchemy model's title + chat JSON,
|
||||
|
|
@ -349,7 +414,13 @@ class ChatTable:
|
|||
return True
|
||||
|
||||
async def insert_new_chat(
|
||||
self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None
|
||||
self,
|
||||
id: str,
|
||||
user_id: str,
|
||||
form_data: ChatForm,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
internal_meta: dict | None = None,
|
||||
) -> ChatModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat = ChatModel(
|
||||
|
|
@ -361,6 +432,9 @@ class ChatTable:
|
|||
),
|
||||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'folder_id': form_data.folder_id,
|
||||
'meta': internal_meta or {},
|
||||
'variables': form_data.variables or {},
|
||||
'current_message_id': self.get_current_message_id(form_data.chat),
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
'last_read_at': int(time.time()),
|
||||
|
|
@ -370,12 +444,17 @@ class ChatTable:
|
|||
chat_item = Chat(**chat.model_dump())
|
||||
session.add(chat_item)
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
|
||||
# Dual-write initial messages to chat_message table
|
||||
try:
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
await ChatMessages.upsert_message(
|
||||
|
|
@ -389,6 +468,50 @@ class ChatTable:
|
|||
|
||||
return ChatModel.model_validate(chat_item) if chat_item else None
|
||||
|
||||
async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]:
|
||||
async with get_async_db_context() as session:
|
||||
result = await session.execute(
|
||||
select(Chat.id).where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['parent_chat_id'].as_string() == parent_chat_id,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_internal_chat_by_note_id(
|
||||
self, note_id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> ChatModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['type'].as_string() == 'note',
|
||||
Chat.meta['note_id'].as_string() == note_id,
|
||||
)
|
||||
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
|
||||
)
|
||||
chat = result.scalars().first()
|
||||
return ChatModel.model_validate(chat) if chat else None
|
||||
|
||||
async def get_internal_chats_by_note_id(
|
||||
self, note_id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['type'].as_string() == 'note',
|
||||
Chat.meta['note_id'].as_string() == note_id,
|
||||
)
|
||||
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
|
||||
)
|
||||
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
||||
|
||||
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
|
||||
id = str(uuid.uuid4())
|
||||
chat = ChatModel(
|
||||
|
|
@ -398,8 +521,10 @@ class ChatTable:
|
|||
'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'),
|
||||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'meta': form_data.meta,
|
||||
'variables': form_data.variables or {},
|
||||
'pinned': form_data.pinned,
|
||||
'folder_id': form_data.folder_id,
|
||||
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
|
||||
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
|
||||
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
|
||||
}
|
||||
|
|
@ -439,8 +564,14 @@ class ChatTable:
|
|||
|
||||
# Dual-write messages to chat_message table
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
try:
|
||||
|
|
@ -460,6 +591,8 @@ class ChatTable:
|
|||
id: str,
|
||||
chat: dict,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
"""Persist updated chat content, sanitizing null bytes."""
|
||||
try: # load the chat record for in-place mutation
|
||||
|
|
@ -470,8 +603,11 @@ class ChatTable:
|
|||
|
||||
chat_item.chat = self._clean_null_bytes(chat)
|
||||
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
|
||||
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
|
||||
chat_item.updated_at = int(time.time())
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
|
@ -479,17 +615,98 @@ class ChatTable:
|
|||
except Exception:
|
||||
return
|
||||
|
||||
async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
|
||||
async def update_chat_variables_by_id(
|
||||
self,
|
||||
id: str,
|
||||
variables: dict | None,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
chat_item.variables = variables if isinstance(variables, dict) else {}
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_chat_last_read_at_by_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> tuple[int, bool] | None:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat = await session.get(Chat, id)
|
||||
if chat and chat.user_id == user_id:
|
||||
chat.last_read_at = int(time.time())
|
||||
last_read_at = int(time.time())
|
||||
was_unread = chat.last_read_at is None or chat.updated_at > chat.last_read_at
|
||||
chat.last_read_at = last_read_at
|
||||
await session.commit()
|
||||
return True
|
||||
return False
|
||||
return last_read_at, was_unread
|
||||
return None
|
||||
except Exception:
|
||||
return False
|
||||
return None
|
||||
|
||||
async def mark_chat_unread_by_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> ChatTitleIdResponse | None:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat = await session.get(Chat, id)
|
||||
if chat and chat.user_id == user_id:
|
||||
chat.last_read_at = 0
|
||||
await session.commit()
|
||||
return ChatTitleIdResponse(
|
||||
id=chat.id,
|
||||
title=chat.title,
|
||||
updated_at=chat.updated_at,
|
||||
created_at=chat.created_at,
|
||||
last_read_at=chat.last_read_at,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def mark_chats_read_by_folder_ids(
|
||||
self, user_id: str, folder_ids: list[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(
|
||||
update(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.folder_id.in_(folder_ids),
|
||||
Chat.archived == False,
|
||||
Chat.meta['internal'].as_boolean().is_not(True),
|
||||
)
|
||||
.values(last_read_at=Chat.updated_at)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
async def mark_chats_read_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(
|
||||
update(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.archived == False,
|
||||
Chat.meta['internal'].as_boolean().is_not(True),
|
||||
)
|
||||
.values(last_read_at=Chat.updated_at)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None:
|
||||
try:
|
||||
|
|
@ -501,7 +718,6 @@ class ChatTable:
|
|||
chat_item.title = clean_title
|
||||
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -519,7 +735,6 @@ class ChatTable:
|
|||
# Single meta update
|
||||
chat.meta = {**chat.meta, 'tags': new_tag_ids}
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
|
||||
# Batch-create any missing tag rows
|
||||
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
|
||||
|
|
@ -702,12 +917,17 @@ class ChatTable:
|
|||
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
||||
|
||||
async def upsert_message_to_chat_by_id_and_message_id(
|
||||
self, id: str, message_id: str, message: dict
|
||||
self, id: str, message_id: str, message: dict, *, touch: bool = True
|
||||
) -> ChatModel | None:
|
||||
chat = await self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
if not message.get('content'):
|
||||
output_text = get_output_text(message.get('output'))
|
||||
if output_text:
|
||||
message['content'] = output_text
|
||||
|
||||
# Sanitize message content for null characters before upserting
|
||||
if isinstance(message.get('content'), str):
|
||||
message['content'] = sanitize_text_for_db(message['content'])
|
||||
|
|
@ -771,7 +991,7 @@ class ChatTable:
|
|||
except Exception as e:
|
||||
log.warning(f'Failed to write to chat_message table: {e}')
|
||||
|
||||
return await self.update_chat_by_id(id, chat)
|
||||
return await self.update_chat_by_id(id, chat, touch=touch)
|
||||
|
||||
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
|
||||
chat_model = await self.get_chat_by_id(id)
|
||||
|
|
@ -809,7 +1029,7 @@ class ChatTable:
|
|||
history['messages'][message_id]['statusHistory'] = status_history
|
||||
|
||||
chat['history'] = history
|
||||
return await self.update_chat_by_id(id, chat)
|
||||
return await self.update_chat_by_id(id, chat, touch=False)
|
||||
|
||||
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
|
||||
async with get_async_db_context() as session:
|
||||
|
|
@ -851,7 +1071,6 @@ class ChatTable:
|
|||
# Set share_id on the original chat
|
||||
chat.share_id = shared.id
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat) # return the updated original
|
||||
|
||||
# refresh helper
|
||||
|
|
@ -898,7 +1117,6 @@ class ChatTable:
|
|||
chat = await session.get(Chat, id)
|
||||
chat.share_id = share_id
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -911,7 +1129,6 @@ class ChatTable:
|
|||
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)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -925,7 +1142,6 @@ class ChatTable:
|
|||
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)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -951,6 +1167,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
|
||||
user_id=user_id, archived=True
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
|
|
@ -998,7 +1215,8 @@ class ChatTable:
|
|||
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))
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_chat_list_by_user_id(
|
||||
|
|
@ -1027,6 +1245,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
if not include_archived:
|
||||
stmt = stmt.filter_by(archived=False)
|
||||
|
||||
|
|
@ -1074,6 +1293,8 @@ class ChatTable:
|
|||
include_archived: bool = False,
|
||||
include_folders: bool = False,
|
||||
include_pinned: bool = False,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
skip: int | None = None,
|
||||
limit: int | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
|
|
@ -1082,6 +1303,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if not include_folders:
|
||||
stmt = stmt.filter_by(folder_id=None)
|
||||
|
|
@ -1092,7 +1314,7 @@ class ChatTable:
|
|||
if not include_archived:
|
||||
stmt = stmt.filter_by(archived=False)
|
||||
|
||||
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
|
|
@ -1123,9 +1345,9 @@ class ChatTable:
|
|||
db: AsyncSession | None = None,
|
||||
) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc())
|
||||
)
|
||||
stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.scalars().all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
|
|
@ -1170,6 +1392,7 @@ class ChatTable:
|
|||
select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name'))
|
||||
.join(chat_ids, chat_ids.c.chat_id == Chat.id)
|
||||
.outerjoin(User, User.id == Chat.user_id)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
|
||||
order_by = filter.get('order_by') if filter else None
|
||||
|
|
@ -1226,7 +1449,6 @@ class ChatTable:
|
|||
flag_modified(chat_item, 'chat')
|
||||
if self._sanitize_chat_row(chat_item) or repaired_history:
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
|
|
@ -1268,7 +1490,6 @@ class ChatTable:
|
|||
flag_modified(chat, 'chat')
|
||||
if self._sanitize_chat_row(chat) or repaired_history:
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
|
|
@ -1299,12 +1520,74 @@ class ChatTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def count_unread_by_folder_ids(
|
||||
self,
|
||||
user_id: str,
|
||||
folder_ids: list[str],
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, int]:
|
||||
if not folder_ids:
|
||||
return {}
|
||||
|
||||
unfinished_assistant = (
|
||||
select(ChatMessage.id)
|
||||
.where(ChatMessage.chat_id == Chat.id)
|
||||
.where(ChatMessage.role == 'assistant')
|
||||
.where(ChatMessage.done.is_(False))
|
||||
.exists()
|
||||
)
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat.folder_id, func.count(Chat.id))
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.folder_id.in_(folder_ids),
|
||||
Chat.archived == False,
|
||||
Chat.updated_at > func.coalesce(Chat.last_read_at, 0),
|
||||
~unfinished_assistant,
|
||||
)
|
||||
.group_by(Chat.folder_id)
|
||||
)
|
||||
return {folder_id: count for folder_id, count in result.all() if folder_id}
|
||||
|
||||
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(Chat).order_by(Chat.updated_at.desc()))
|
||||
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.scalars().all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter))
|
||||
total_chats = int(result.scalar() or 0)
|
||||
|
||||
messages_stmt = (
|
||||
select(ChatMessage.chat_id, ChatMessage.created_at)
|
||||
.join(Chat, Chat.id == ChatMessage.chat_id)
|
||||
.where(*chat_filter, ChatMessage.created_at.isnot(None))
|
||||
.order_by(ChatMessage.chat_id, ChatMessage.created_at.asc())
|
||||
)
|
||||
messages_result = await session.execute(messages_stmt)
|
||||
last_message_at_by_chat: dict[str, int] = {}
|
||||
active_seconds_by_chat: dict[str, int] = {}
|
||||
|
||||
for chat_id, created_at in messages_result.all():
|
||||
timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at)
|
||||
last_message_at = last_message_at_by_chat.get(chat_id)
|
||||
if last_message_at is not None:
|
||||
delta = timestamp - last_message_at
|
||||
if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS:
|
||||
active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta
|
||||
last_message_at_by_chat[chat_id] = timestamp
|
||||
|
||||
return {
|
||||
'total_chats': total_chats,
|
||||
'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0),
|
||||
}
|
||||
|
||||
# list user conversations
|
||||
async def get_chats_by_user_id(
|
||||
self,
|
||||
|
|
@ -1316,6 +1599,7 @@ class ChatTable:
|
|||
) -> ChatListResponse:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat).filter_by(user_id=user_id)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if filter:
|
||||
if filter.get('updated_at'):
|
||||
|
|
@ -1359,11 +1643,11 @@ class ChatTable:
|
|||
self, user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
|
||||
.filter_by(user_id=user_id, pinned=True, archived=False)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id, pinned=True, archived=False
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.all()
|
||||
return [
|
||||
ChatTitleIdResponse.model_validate(
|
||||
|
|
@ -1380,9 +1664,9 @@ class ChatTable:
|
|||
|
||||
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc())
|
||||
)
|
||||
stmt = select(Chat).filter_by(user_id=user_id, archived=True)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
||||
|
||||
# search user conversations
|
||||
|
|
@ -1453,6 +1737,7 @@ class ChatTable:
|
|||
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat).filter(Chat.user_id == user_id)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if is_archived is not None:
|
||||
stmt = stmt.filter(Chat.archived == is_archived)
|
||||
|
|
@ -1528,9 +1813,23 @@ class ChatTable:
|
|||
postgres_content_sql = """
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
||||
WHERE json_typeof(message->'content') = 'string'
|
||||
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
|
||||
FROM chat_message AS message
|
||||
WHERE message.chat_id = Chat.id
|
||||
AND message.user_id = Chat.user_id
|
||||
AND json_typeof(message.content) = 'string'
|
||||
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
|
||||
WHERE json_typeof(history_message.value->'content') = 'string'
|
||||
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
|
||||
WHERE json_typeof(legacy_message->'content') = 'string'
|
||||
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
"""
|
||||
|
||||
|
|
@ -1586,6 +1885,8 @@ class ChatTable:
|
|||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 60,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
async with get_async_db_context(db) as session:
|
||||
|
|
@ -1594,8 +1895,9 @@ class ChatTable:
|
|||
.filter_by(folder_id=folder_id, user_id=user_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir))
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
|
|
@ -1622,6 +1924,9 @@ class ChatTable:
|
|||
folder_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 60,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
unread_for_user_id: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
|
||||
|
|
@ -1631,8 +1936,9 @@ class ChatTable:
|
|||
.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)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir, unread_for_user_id))
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
|
|
@ -1653,6 +1959,22 @@ class ChatTable:
|
|||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def count_all_chats_by_folder_id(
|
||||
self,
|
||||
folder_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = (
|
||||
select(func.count(Chat.id))
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_chats_by_folder_ids_and_user_id(
|
||||
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
|
|
@ -1662,6 +1984,7 @@ class ChatTable:
|
|||
.filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
.order_by(Chat.updated_at.desc())
|
||||
)
|
||||
|
||||
|
|
@ -1679,8 +2002,12 @@ class ChatTable:
|
|||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
chat.pinned = False
|
||||
if folder_id is not None:
|
||||
# Folder listings only show unarchived chats, so moving an archived
|
||||
# chat into a folder would otherwise have no visible effect: the chat
|
||||
# stays in the archived list and never appears in the folder.
|
||||
chat.archived = False
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1707,6 +2034,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
tag_id = tag_name.replace(' ', '_').lower()
|
||||
|
||||
bind = await session.connection()
|
||||
|
|
@ -1759,7 +2087,6 @@ class ChatTable:
|
|||
'tags': list(set(chat.meta.get('tags', []) + [tag_id])),
|
||||
}
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1769,6 +2096,7 @@ class ChatTable:
|
|||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
tag_id = tag_name.replace(' ', '_').lower()
|
||||
|
||||
bind = await session.connection()
|
||||
|
|
@ -1816,7 +2144,8 @@ class ChatTable:
|
|||
self, folder_id: str, 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, folder_id=folder_id))
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
|
|
@ -1829,9 +2158,8 @@ class ChatTable:
|
|||
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))
|
||||
)
|
||||
stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folders '{folder_ids}': {count}")
|
||||
|
|
@ -2058,7 +2386,6 @@ class ChatTable:
|
|||
return None
|
||||
chat.tasks = tasks
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -46,8 +46,10 @@ API_CONFIG_FIELDS = (
|
|||
'auth_type',
|
||||
'headers',
|
||||
'azure',
|
||||
'api_type',
|
||||
'api_version',
|
||||
'extra_params',
|
||||
'passthrough_params',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -247,6 +249,10 @@ class Config(Base):
|
|||
now = int(time.time())
|
||||
new_count = 0
|
||||
for key, value in defaults.items():
|
||||
# Skip keys the DB is not authoritative for (e.g. oauth.* while
|
||||
# ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths.
|
||||
if not Config.persistent_enabled_for(key):
|
||||
continue
|
||||
if key not in existing_keys:
|
||||
value = _json_value(value)
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ class FeedbackTable:
|
|||
result = Feedback(**feedback.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FeedbackModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ class FilesTable:
|
|||
result = File(**file.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FileModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class FolderNameIdResponse(BaseModel):
|
|||
meta: Optional[FolderMetadataResponse] = None
|
||||
parent_id: Optional[str] = None
|
||||
is_expanded: bool = False
|
||||
unread_count: int = 0
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import time
|
|||
|
||||
# local imports
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import UserResponse, Users
|
||||
from open_webui.models.users import User, UserResponse, Users, UserSettings
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
|
||||
|
|
@ -42,7 +42,7 @@ class FunctionMeta(BaseModel):
|
|||
|
||||
class FunctionModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
|
|
@ -58,7 +58,7 @@ class FunctionModel(BaseModel):
|
|||
# --- form / schema definitions ---
|
||||
class FunctionWithValvesModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
|
|
@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel):
|
|||
|
||||
class FunctionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
type: str
|
||||
name: str
|
||||
meta: FunctionMeta
|
||||
|
|
@ -129,7 +129,6 @@ class FunctionsTable:
|
|||
result = Function(**function.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FunctionModel.model_validate(result)
|
||||
else:
|
||||
|
|
@ -275,6 +274,18 @@ class FunctionsTable:
|
|||
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
|
||||
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
|
||||
|
||||
async def get_active_function_ids_by_type(
|
||||
self, type: str, db: AsyncSession | None = None
|
||||
) -> list[tuple[str, bool]]:
|
||||
"""Return (id, is_global) for active functions without fetching plugin source."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True))
|
||||
return [(id, bool(is_global)) for id, is_global in result.all()]
|
||||
|
||||
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
|
||||
"""Return (id, is_global) for active filters without fetching plugin source."""
|
||||
return await self.get_active_function_ids_by_type('filter', db=db)
|
||||
|
||||
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
|
||||
|
|
@ -283,8 +294,8 @@ class FunctionsTable:
|
|||
async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
function = await db.get(Function, id)
|
||||
return decrypt_valves(function.valves if function else None)
|
||||
result = await db.execute(select(Function.valves).filter_by(id=id))
|
||||
return decrypt_valves(result.scalar_one_or_none())
|
||||
except Exception as e:
|
||||
log.exception(f'Error getting function valves by id {id}: {e}')
|
||||
return None
|
||||
|
|
@ -300,8 +311,7 @@ class FunctionsTable:
|
|||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
|
||||
functions = result.all()
|
||||
return {f.id: decrypt_valves(f.valves) for f in functions}
|
||||
return {id: decrypt_valves(valves) for id, valves in result.all()}
|
||||
except Exception as e:
|
||||
log.exception(f'Error batch-fetching function valves: {e}')
|
||||
return {}
|
||||
|
|
@ -315,7 +325,6 @@ class FunctionsTable:
|
|||
function.valves = encrypt_valves(valves)
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -335,7 +344,6 @@ class FunctionsTable:
|
|||
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
|
|
@ -347,8 +355,11 @@ class FunctionsTable:
|
|||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> dict | None:
|
||||
try:
|
||||
user = await Users.get_user_by_id(user_id, db=db)
|
||||
user_settings = user.settings.model_dump() if user.settings else {}
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(User.settings).filter_by(id=user_id))
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
user_settings = UserSettings(**settings).model_dump() if settings else {}
|
||||
|
||||
# Check if user has "functions" and "valves" settings
|
||||
if 'functions' not in user_settings:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ from sqlalchemy.orm import defer
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Columns the knowledge base list may be ordered by; anything else falls back to the default.
|
||||
KNOWLEDGE_SORTABLE_FIELDS = {'name', 'created_at', 'updated_at'}
|
||||
|
||||
####################
|
||||
# Knowledge DB Schema
|
||||
# Let what was gathered here outlast the one who gathered it,
|
||||
|
|
@ -149,6 +152,7 @@ class KnowledgeDirectoryForm(BaseModel):
|
|||
####################
|
||||
class KnowledgeUserModel(KnowledgeModel):
|
||||
user: Optional[UserResponse] = None
|
||||
file_count: int | None = None
|
||||
|
||||
|
||||
class KnowledgeResponse(KnowledgeModel):
|
||||
|
|
@ -308,7 +312,17 @@ class KnowledgeTable:
|
|||
permission='read',
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
order_by = (filter or {}).get('order_by')
|
||||
direction = (filter or {}).get('direction')
|
||||
|
||||
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
|
||||
column = getattr(Knowledge, order_by)
|
||||
if (direction or 'desc').lower() == 'asc':
|
||||
stmt = stmt.order_by(column.asc(), Knowledge.id.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(column.desc(), Knowledge.id.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
|
@ -322,6 +336,14 @@ class KnowledgeTable:
|
|||
|
||||
knowledge_ids = [kb.id for kb, _ in items]
|
||||
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
|
||||
file_counts = {}
|
||||
if knowledge_ids:
|
||||
file_count_result = await db.execute(
|
||||
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
|
||||
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
|
||||
.group_by(KnowledgeFile.knowledge_id)
|
||||
)
|
||||
file_counts = dict(file_count_result.all())
|
||||
|
||||
knowledge_bases = []
|
||||
for knowledge_base, user in items:
|
||||
|
|
@ -336,6 +358,7 @@ class KnowledgeTable:
|
|||
)
|
||||
).model_dump(),
|
||||
'user': (UserModel.model_validate(user).model_dump() if user else None),
|
||||
'file_count': file_counts.get(knowledge_base.id, 0),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -469,20 +492,16 @@ class KnowledgeTable:
|
|||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
result = []
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user_id:
|
||||
result.append(knowledge_base)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(knowledge_base)
|
||||
return result
|
||||
# One grants query for all non-owned knowledge bases instead of one each
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
|
||||
|
||||
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
|
||||
try:
|
||||
|
|
@ -665,9 +684,25 @@ class KnowledgeTable:
|
|||
async def get_file_metadatas_by_id(
|
||||
self, knowledge_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[FileMetadataResponse]:
|
||||
"""Column-only listing: File.data holds each file's full extracted
|
||||
text, which metadata views must never load."""
|
||||
try:
|
||||
files = await self.get_files_by_id(knowledge_id, db=db)
|
||||
return [FileMetadataResponse(**file.model_dump()) for file in files]
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
|
||||
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||
)
|
||||
return [
|
||||
FileMetadataResponse(
|
||||
id=row.id,
|
||||
hash=row.hash,
|
||||
meta=row.meta,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in result.all()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Literal
|
|||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select
|
||||
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ class Memory(Base): # user memory store
|
|||
"""Stores user-created memory entries linked to a vector collection."""
|
||||
|
||||
__tablename__ = 'memory'
|
||||
__table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),)
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String, index=True)
|
||||
|
|
@ -70,7 +71,6 @@ class MemoriesTable:
|
|||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return MemoryModel.model_validate(record) if record else None
|
||||
|
||||
async def update_memory_by_id_and_user_id(
|
||||
|
|
@ -101,7 +101,6 @@ class MemoriesTable:
|
|||
memory.updated_at = int(time.time())
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(memory)
|
||||
return MemoryModel.model_validate(memory)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
|
@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
|
|||
_warned_profile_urls: set[str] = set()
|
||||
|
||||
|
||||
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
|
||||
"""Drop duplicated extracted text from ModelMeta.knowledge."""
|
||||
if not isinstance(knowledge, list):
|
||||
return knowledge
|
||||
|
||||
sanitized = []
|
||||
|
||||
for item in knowledge:
|
||||
if not isinstance(item, dict):
|
||||
sanitized.append(item)
|
||||
continue
|
||||
|
||||
next_item = item
|
||||
data = item.get('data')
|
||||
if isinstance(data, dict) and 'content' in data:
|
||||
next_item = deepcopy(item)
|
||||
next_item.get('data', {}).pop('content', None)
|
||||
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
if isinstance(file_data, dict) and 'content' in file_data:
|
||||
if next_item is item:
|
||||
next_item = deepcopy(item)
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
file_data.pop('content', None)
|
||||
|
||||
sanitized.append(next_item)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# --- Models DB Schema ---
|
||||
|
||||
|
||||
|
|
@ -37,6 +70,7 @@ class ModelMeta(BaseModel):
|
|||
profile_image_url: str | None = None
|
||||
description: str | None = Field(default=None, description='User-facing description of the model.')
|
||||
capabilities: dict | None = None
|
||||
knowledge: list[Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
|
@ -56,6 +90,11 @@ class ModelMeta(BaseModel):
|
|||
)
|
||||
return None
|
||||
|
||||
@field_validator('knowledge', mode='before')
|
||||
@classmethod
|
||||
def strip_knowledge_content(cls, v):
|
||||
return strip_extracted_content_from_model_knowledge(v)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def normalize_tags(cls, data):
|
||||
|
|
@ -152,6 +191,14 @@ class ModelsTable:
|
|||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ModelModel:
|
||||
if isinstance(model.meta, dict):
|
||||
knowledge = model.meta.get('knowledge')
|
||||
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
|
||||
if stripped_knowledge != knowledge:
|
||||
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
|
||||
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
|
||||
model_data['access_grants'] = (
|
||||
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
|
||||
|
|
@ -173,7 +220,6 @@ class ModelsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
|
||||
|
||||
if result:
|
||||
|
|
@ -256,26 +302,26 @@ class ModelsTable:
|
|||
]
|
||||
|
||||
async def get_models_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'write',
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> list[ModelUserResponse]:
|
||||
models = await self.get_models(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
|
||||
result = []
|
||||
for model in models:
|
||||
if model.user_id == user_id:
|
||||
result.append(model)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(model)
|
||||
return result
|
||||
# One grants query for all non-owned models instead of one per model
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[model.id for model in models if model.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
|
|
@ -483,7 +529,6 @@ class ModelsTable:
|
|||
model.is_active = not model.is_active
|
||||
model.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(model)
|
||||
|
||||
return await self._to_model_model(model, db=db)
|
||||
except Exception:
|
||||
|
|
@ -515,7 +560,6 @@ class ModelsTable:
|
|||
return None
|
||||
model_obj.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(model_obj)
|
||||
return await self._to_model_model(model_obj, db=db)
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
|
||||
|
|
|
|||
|
|
@ -312,6 +312,9 @@ class NoteTable:
|
|||
if 'meta' in form_data:
|
||||
note.meta = {**note.meta, **form_data['meta']}
|
||||
|
||||
if not db.is_modified(note) and 'access_grants' not in form_data:
|
||||
return await self._to_note_model(note, db=db)
|
||||
|
||||
if 'access_grants' in form_data:
|
||||
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ class OAuthSessionTable:
|
|||
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
|
||||
if result:
|
||||
# Make a copy of the model data before closing session
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ class PromptHistoryTable:
|
|||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return PromptHistoryModel.model_validate(history)
|
||||
|
||||
async def get_history_by_prompt_id(
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ class PromptsTable:
|
|||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record) # populate generated defaults
|
||||
|
||||
await AccessGrants.set_access_grants(
|
||||
'prompt',
|
||||
|
|
@ -169,7 +168,6 @@ class PromptsTable:
|
|||
if history_entry:
|
||||
record.version_id = history_entry.id
|
||||
await session.commit()
|
||||
await session.refresh(record) # re-read version_id
|
||||
|
||||
return await self._to_prompt_model(record, db=session)
|
||||
except Exception as e:
|
||||
|
|
@ -637,7 +635,6 @@ class PromptsTable:
|
|||
prompt.is_active = not prompt.is_active
|
||||
prompt.updated_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(prompt)
|
||||
return await self._to_prompt_model(prompt, db=session)
|
||||
return None
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ class SkillsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
|
||||
if result:
|
||||
return await self._to_skill_model(result, db=db)
|
||||
|
|
@ -259,7 +258,26 @@ class SkillsTable:
|
|||
permission='read',
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
|
||||
if order_by == 'name':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.name.desc())
|
||||
elif order_by == 'created_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.created_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.created_at.desc())
|
||||
elif order_by == 'updated_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.updated_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
|
|
@ -307,8 +325,8 @@ class SkillsTable:
|
|||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
|
||||
|
||||
skill = await db.get(Skill, id)
|
||||
await db.refresh(skill)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
skill = await db.get(Skill, id, populate_existing=True)
|
||||
return await self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -324,7 +342,6 @@ class SkillsTable:
|
|||
skill.is_active = not skill.is_active
|
||||
skill.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(skill)
|
||||
|
||||
return await self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ class TagTable:
|
|||
record = Tag(id=tag_id, user_id=user_id, name=name)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return TagModel.model_validate(record) if record else None
|
||||
except Exception as e:
|
||||
log.exception('Error inserting tag %r: %s', name, e)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ class ToolMeta(BaseModel):
|
|||
|
||||
class ToolModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
content: str
|
||||
# None when listed with defer_content=True (source skipped for listings)
|
||||
content: str | None = None
|
||||
specs: list[dict]
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
|
@ -65,7 +66,7 @@ class ToolUserModel(ToolModel):
|
|||
|
||||
class ToolResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
|
@ -131,7 +132,6 @@ class ToolsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db)
|
||||
if result:
|
||||
return await self._to_tool_model(result, db=db)
|
||||
|
|
@ -171,11 +171,18 @@ class ToolsTable:
|
|||
|
||||
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Tool).order_by(Tool.updated_at.desc())
|
||||
if defer_content:
|
||||
stmt = stmt
|
||||
result = await db.execute(stmt)
|
||||
all_tools = result.scalars().all()
|
||||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
|
||||
).order_by(Tool.updated_at.desc())
|
||||
)
|
||||
all_tools = result.all()
|
||||
else:
|
||||
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||
all_tools = result.scalars().all()
|
||||
|
||||
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||
tool_ids = [tool.id for tool in all_tools]
|
||||
|
|
@ -214,20 +221,16 @@ class ToolsTable:
|
|||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
result = []
|
||||
for tool in tools:
|
||||
if tool.user_id == user_id:
|
||||
result.append(tool)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_id=tool.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(tool)
|
||||
return result
|
||||
# One grants query for all non-owned tools instead of one per tool
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
|
||||
|
||||
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
try:
|
||||
|
|
@ -301,8 +304,8 @@ class ToolsTable:
|
|||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
|
||||
|
||||
tool = await db.get(Tool, id)
|
||||
await db.refresh(tool)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
tool = await db.get(Tool, id, populate_existing=True)
|
||||
return await self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
|||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.utils.misc import throttle
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
|
|
@ -69,6 +69,7 @@ class User(Base): # identity & profile
|
|||
|
||||
# Metadata
|
||||
info = Column(JSON, nullable=True)
|
||||
variables = Column(JSON, nullable=True)
|
||||
settings = Column(JSON, nullable=True)
|
||||
oauth = Column(JSON, nullable=True)
|
||||
scim = Column(JSON, nullable=True)
|
||||
|
|
@ -105,6 +106,7 @@ class UserModel(BaseModel):
|
|||
status_expires_at: int | None = None
|
||||
|
||||
info: dict | None = None
|
||||
variables: dict = Field(default_factory=dict, exclude=True)
|
||||
settings: UserSettings | None = None
|
||||
|
||||
oauth: dict | None = None
|
||||
|
|
@ -126,6 +128,11 @@ class UserModel(BaseModel):
|
|||
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
|
||||
return self
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class UserStatusModel(UserModel):
|
||||
is_active: bool = False
|
||||
|
|
@ -302,7 +309,6 @@ class UsersTable:
|
|||
result = User(**user.model_dump())
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return user if result else None
|
||||
|
||||
# database read methods
|
||||
|
|
@ -566,13 +572,6 @@ class UsersTable:
|
|||
row = (await session.execute(stmt)).scalars().first()
|
||||
return UserModel.model_validate(row) if row else None
|
||||
|
||||
async def get_user_webhook_url_by_id(self, id: str, db: AsyncSession | None = None) -> str | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
user = await session.get(User, id)
|
||||
if user and user.settings:
|
||||
return user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
|
||||
return None
|
||||
|
||||
async def get_num_users_active_today(self, db: AsyncSession | None = None) -> int | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
current_timestamp = int(time.time())
|
||||
|
|
@ -589,7 +588,6 @@ class UsersTable:
|
|||
return None
|
||||
user.role = role
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_status_by_id(
|
||||
|
|
@ -602,7 +600,6 @@ class UsersTable:
|
|||
for key, value in form_data.model_dump(exclude_none=True).items():
|
||||
setattr(user, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_profile_image_url_by_id(
|
||||
|
|
@ -622,7 +619,6 @@ class UsersTable:
|
|||
return None
|
||||
user.profile_image_url = profile_image_url
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
|
|
@ -643,7 +639,6 @@ class UsersTable:
|
|||
oauth[provider] = {'sub': sub}
|
||||
user.oauth = oauth
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_scim_by_id(
|
||||
|
|
@ -662,7 +657,6 @@ class UsersTable:
|
|||
scim[provider] = {'external_id': external_id}
|
||||
user.scim = scim
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
|
||||
|
|
@ -673,7 +667,6 @@ class UsersTable:
|
|||
for key, value in updated.items():
|
||||
setattr(user, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
# settings update helper
|
||||
|
|
@ -688,7 +681,6 @@ class UsersTable:
|
|||
user_settings.update(updated)
|
||||
user.settings = user_settings
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
|
||||
|
|
@ -735,8 +727,8 @@ class UsersTable:
|
|||
|
||||
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(User).where(User.id.in_(user_ids)))
|
||||
return [u.id for u in result.scalars().all()]
|
||||
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
|
|
@ -762,11 +754,11 @@ class UsersTable:
|
|||
|
||||
async def is_user_active(self, user_id: str, db: AsyncSession | None = None) -> bool:
|
||||
async with get_async_db_context(db) as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user and user.last_active_at:
|
||||
last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id))
|
||||
if last_active_at:
|
||||
# Consider user active if last_active_at within the last 3 minutes
|
||||
three_minutes_ago = int(time.time()) - 180
|
||||
return user.last_active_at >= three_minutes_ago
|
||||
return last_active_at >= three_minutes_ago
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from urllib.parse import quote
|
|||
import requests
|
||||
from langchain_core.document_loaders import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||
from open_webui.utils.headers import include_user_info_headers, parse_custom_headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader):
|
|||
api_key: str,
|
||||
mime_type=None,
|
||||
user=None,
|
||||
user_groups=None,
|
||||
headers=None,
|
||||
metadata=None,
|
||||
**kwargs,
|
||||
|
|
@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader):
|
|||
self.mime_type = mime_type
|
||||
|
||||
self.user = user
|
||||
self.user_groups = user_groups
|
||||
self.headers = headers
|
||||
self.metadata = metadata
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
headers.update(get_custom_headers(self.headers, self.user, self.metadata))
|
||||
headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups))
|
||||
|
||||
if self.user is not None:
|
||||
headers = include_user_info_headers(headers, self.user)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
|
|||
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
|
||||
from open_webui.retrieval.loaders.mineru import MinerULoader
|
||||
from open_webui.retrieval.loaders.mistral import MistralLoader
|
||||
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
|
||||
from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader
|
||||
from open_webui.utils.headers import get_user_groups_for_custom_headers
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -246,6 +247,7 @@ class Loader:
|
|||
def __init__(self, engine: str = '', **kwargs):
|
||||
self.engine = engine
|
||||
self.user = kwargs.get('user', None)
|
||||
self.user_groups = kwargs.get('user_groups', None)
|
||||
self.metadata = kwargs.get('metadata', {})
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
|
@ -264,6 +266,13 @@ class Loader:
|
|||
loop for the entire parse — minutes for large PDFs. This offloads
|
||||
the work to a worker thread so the loop stays responsive.
|
||||
"""
|
||||
# Group lookup is async-only, so it must happen before `load`
|
||||
# is offloaded to a thread without a running event loop.
|
||||
if self.engine == 'external' and self.user_groups is None:
|
||||
self.user_groups = await get_user_groups_for_custom_headers(
|
||||
self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user
|
||||
)
|
||||
|
||||
return await asyncio.to_thread(self.load, filename, file_content_type, file_path)
|
||||
|
||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||
|
|
@ -303,13 +312,20 @@ class Loader:
|
|||
try:
|
||||
raw.decode('utf-8')
|
||||
return 'utf-8'
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
except UnicodeDecodeError as e:
|
||||
first_non_utf8 = e.start
|
||||
|
||||
# Use chardet as a hint, not as ground truth
|
||||
import chardet
|
||||
|
||||
detected = chardet.detect(raw)
|
||||
# chardet is pure Python (~1.3s/MB), so sample around the first bad byte
|
||||
window = 256 * 1024
|
||||
sample_start = max(0, first_non_utf8 - window // 2)
|
||||
sample = raw[sample_start : sample_start + window]
|
||||
detected = chardet.detect(sample)
|
||||
# A stray byte can sit far from the real payload, leaving the sample with nothing to read
|
||||
if len(sample.translate(None, delete=bytes(range(128)))) < 64 and len(sample) < len(raw):
|
||||
detected = chardet.detect(raw)
|
||||
detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
|
||||
|
||||
# Map chardet's detected encoding to the correct superset codec.
|
||||
|
|
@ -422,6 +438,7 @@ class Loader:
|
|||
api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'),
|
||||
mime_type=file_content_type,
|
||||
user=self.user,
|
||||
user_groups=self.user_groups,
|
||||
headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'),
|
||||
metadata={
|
||||
**self.metadata,
|
||||
|
|
@ -554,8 +571,14 @@ class Loader:
|
|||
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
|
||||
file_path=file_path,
|
||||
use_base64=self.kwargs.get('MISTRAL_OCR_USE_BASE64', False),
|
||||
user=self.user,
|
||||
)
|
||||
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
|
||||
elif (
|
||||
self.engine == 'paddleocr_vl'
|
||||
and self.kwargs.get('PADDLEOCR_VL_BASE_URL')
|
||||
and self.kwargs.get('PADDLEOCR_VL_TOKEN')
|
||||
and file_ext in PADDLEOCR_VL_SUPPORTED_EXTENSIONS
|
||||
):
|
||||
loader = PaddleOCRVLLoader(
|
||||
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
|
||||
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import os
|
|||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -39,6 +40,7 @@ class MistralLoader:
|
|||
max_retries: int = 3,
|
||||
enable_debug_logging: bool = False,
|
||||
use_base64: bool = False,
|
||||
user: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initializes the loader with enhanced features.
|
||||
|
|
@ -50,6 +52,8 @@ class MistralLoader:
|
|||
max_retries: Maximum number of retry attempts.
|
||||
enable_debug_logging: Enable detailed debug logs.
|
||||
use_base64: Send the document as a data URL instead of uploading it first.
|
||||
user: The requesting user, forwarded to Mistral via user-info headers
|
||||
when ENABLE_FORWARD_USER_INFO_HEADERS is enabled.
|
||||
"""
|
||||
if not api_key:
|
||||
raise ValueError('API key cannot be empty.')
|
||||
|
|
@ -63,6 +67,7 @@ class MistralLoader:
|
|||
self.max_retries = max_retries
|
||||
self.debug = enable_debug_logging
|
||||
self.use_base64 = use_base64
|
||||
self.user = user
|
||||
|
||||
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
|
||||
# This prevents long-running OCR operations from affecting quick operations
|
||||
|
|
@ -82,6 +87,8 @@ class MistralLoader:
|
|||
'Authorization': f'Bearer {self.api_key}',
|
||||
'User-Agent': 'OpenWebUI-MistralLoader/2.0', # Helps API provider track usage
|
||||
}
|
||||
if self.user is not None and ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
self.headers = include_user_info_headers(self.headers, self.user)
|
||||
|
||||
def _debug_log(self, message: str, *args) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ from open_webui.env import GLOBAL_LOG_LEVEL
|
|||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PADDLEOCR_VL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
|
||||
PADDLEOCR_VL_SUPPORTED_EXTENSIONS = ['pdf'] + PADDLEOCR_VL_IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
class PaddleOCRVLLoader:
|
||||
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
|
||||
|
|
@ -46,8 +49,7 @@ class PaddleOCRVLLoader:
|
|||
|
||||
# Detect fileType based on file extension
|
||||
ext = self.file_path.lower().split('.')[-1]
|
||||
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
|
||||
file_type = 1 if ext in image_extensions else 0
|
||||
file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0
|
||||
|
||||
payload = {
|
||||
'file': file_data,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from open_webui.env import (
|
|||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.config import Config
|
||||
|
|
@ -48,8 +49,9 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
|||
from open_webui.retrieval.vector.main import GetResult, SearchResult
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.misc import get_content_from_message, get_message_list
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -71,6 +73,20 @@ LOADER_CONFIG_KEYS = {
|
|||
'web_loader_ssl_verification': 'web.loader.ssl_verification',
|
||||
'web_loader_concurrent_requests': 'web.loader.concurrent_requests',
|
||||
'web_search_trust_env': 'web.search.trust_env',
|
||||
'web_loader_engine': 'web.loader.engine',
|
||||
'web_loader_timeout': 'web.loader.timeout',
|
||||
'playwright_ws_url': 'web.loader.playwright_ws_url',
|
||||
'playwright_timeout': 'web.loader.playwright_timeout',
|
||||
'firecrawl_api_key': 'web.loader.firecrawl_api_key',
|
||||
'firecrawl_api_url': 'web.loader.firecrawl_api_url',
|
||||
'firecrawl_timeout': 'web.loader.firecrawl_timeout',
|
||||
'tavily_api_key': 'web.search.tavily_api_key',
|
||||
'tavily_extract_depth': 'web.search.tavily_extract_depth',
|
||||
'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url',
|
||||
'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key',
|
||||
'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language',
|
||||
'external_web_loader_url': 'web.loader.external_web_loader_url',
|
||||
'external_web_loader_api_key': 'web.loader.external_web_loader_api_key',
|
||||
'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',
|
||||
|
|
@ -126,6 +142,7 @@ def get_loader(request, url: str, config: dict):
|
|||
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'),
|
||||
loader_config=config,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1323,6 +1340,23 @@ async def get_sources_from_items(
|
|||
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
|
||||
extracted_collections = []
|
||||
query_results = []
|
||||
folder_items = set()
|
||||
expanded_folders = set()
|
||||
|
||||
items = list(items)
|
||||
for item in items:
|
||||
if item.get('type') != 'folder' or not user:
|
||||
continue
|
||||
folder_id = item.get('id')
|
||||
if not folder_id or folder_id in expanded_folders:
|
||||
continue
|
||||
expanded_folders.add(folder_id)
|
||||
|
||||
folder = await Folders.get_folder_by_id(folder_id)
|
||||
if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)):
|
||||
files = (folder.data or {}).get('files', [])
|
||||
folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict))
|
||||
items.extend(files)
|
||||
|
||||
for item in items:
|
||||
query_result = None
|
||||
|
|
@ -1390,7 +1424,10 @@ async def get_sources_from_items(
|
|||
# Reconstruct the message list in order
|
||||
message_list = get_message_list(messages_map, message_id)
|
||||
message_history = '\n'.join(
|
||||
[f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list]
|
||||
[
|
||||
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
|
||||
for m in message_list
|
||||
]
|
||||
)
|
||||
|
||||
# User has access to the chat
|
||||
|
|
@ -1429,6 +1466,7 @@ async def get_sources_from_items(
|
|||
user.role == 'admin'
|
||||
or file_object.user_id == user.id
|
||||
or await has_access_to_file(item.get('id'), 'read', user)
|
||||
or ('file', item.get('id')) in folder_items
|
||||
):
|
||||
query_result = {
|
||||
'documents': [[file_object.data.get('content', '')]],
|
||||
|
|
@ -1459,6 +1497,7 @@ async def get_sources_from_items(
|
|||
user.role == 'admin'
|
||||
or file_object.user_id == user.id
|
||||
or await has_access_to_file(file_id, 'read', user)
|
||||
or ('file', file_id) in folder_items
|
||||
):
|
||||
if item.get('legacy'):
|
||||
collection_names.append(f'{file_id}')
|
||||
|
|
@ -1478,6 +1517,7 @@ async def get_sources_from_items(
|
|||
resource_id=knowledge_base.id,
|
||||
permission='read',
|
||||
)
|
||||
or ('collection', item.get('id')) in folder_items
|
||||
):
|
||||
if (knowledge_base.meta or {}).get('source') == 'external':
|
||||
query_result = await retrieve_external_knowledge(
|
||||
|
|
@ -1500,6 +1540,7 @@ async def get_sources_from_items(
|
|||
resource_id=knowledge_base.id,
|
||||
permission='read',
|
||||
)
|
||||
or ('collection', item.get('id')) in folder_items
|
||||
):
|
||||
files = await Knowledges.get_files_by_id(knowledge_base.id)
|
||||
|
||||
|
|
@ -1570,7 +1611,7 @@ async def get_sources_from_items(
|
|||
continue
|
||||
|
||||
# Filter out collections the user cannot read
|
||||
if user:
|
||||
if user and (item.get('type'), item.get('id')) not in folder_items:
|
||||
collection_names = await filter_accessible_collections(collection_names, user)
|
||||
if not collection_names:
|
||||
log.debug(f'access denied for all collections in item {item}')
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from pymilvus import Collection, DataType, FieldSchema, connections
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
|
|
@ -202,8 +202,6 @@ class MilvusClient(VectorDBBase):
|
|||
return self._result_to_search_result(result)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: int = -1):
|
||||
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
if not self.has_collection(collection_name):
|
||||
log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}')
|
||||
|
|
@ -218,16 +216,16 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
filter_string = ' && '.join(filter_expressions)
|
||||
|
||||
collection = Collection(f'{self.collection_prefix}_{collection_name}')
|
||||
collection.load()
|
||||
self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}')
|
||||
|
||||
try:
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
)
|
||||
|
||||
iterator = collection.query_iterator(
|
||||
expr=filter_string,
|
||||
iterator = self.client.query_iterator(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
filter=filter_string,
|
||||
output_fields=[
|
||||
'id',
|
||||
'data',
|
||||
|
|
|
|||
|
|
@ -23,14 +23,8 @@ from open_webui.retrieval.vector.main import (
|
|||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from pymilvus import (
|
||||
Collection,
|
||||
CollectionSchema,
|
||||
DataType,
|
||||
FieldSchema,
|
||||
connections,
|
||||
utility,
|
||||
)
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -70,12 +64,7 @@ class MilvusClient(VectorDBBase):
|
|||
def __init__(self):
|
||||
# Milvus collection names can only contain numbers, letters, and underscores.
|
||||
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_')
|
||||
connections.connect(
|
||||
alias='default',
|
||||
uri=MILVUS_URI,
|
||||
token=MILVUS_TOKEN,
|
||||
db_name=MILVUS_DB,
|
||||
)
|
||||
self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Main collection types for multi-tenancy
|
||||
self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories'
|
||||
|
|
@ -116,53 +105,66 @@ class MilvusClient(VectorDBBase):
|
|||
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||
|
||||
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||
fields = [
|
||||
FieldSchema(
|
||||
name='id',
|
||||
dtype=DataType.VARCHAR,
|
||||
is_primary=True,
|
||||
auto_id=False,
|
||||
max_length=36,
|
||||
),
|
||||
FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name='metadata', dtype=DataType.JSON),
|
||||
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
|
||||
]
|
||||
schema = CollectionSchema(fields, 'Shared collection for multi-tenancy')
|
||||
collection = Collection(mt_collection_name, schema)
|
||||
schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy')
|
||||
schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36)
|
||||
schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension)
|
||||
schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH)
|
||||
schema.add_field(field_name='metadata', datatype=DataType.JSON)
|
||||
schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255)
|
||||
|
||||
index_params = {
|
||||
'metric_type': MILVUS_METRIC_TYPE,
|
||||
'index_type': MILVUS_INDEX_TYPE,
|
||||
'params': {},
|
||||
}
|
||||
index_build_params = {}
|
||||
if MILVUS_INDEX_TYPE == 'HNSW':
|
||||
index_params['params'] = {
|
||||
index_build_params = {
|
||||
'M': MILVUS_HNSW_M,
|
||||
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
elif MILVUS_INDEX_TYPE == 'IVF_FLAT':
|
||||
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
|
||||
collection.create_index('vector', index_params)
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
vector_index = self.client.prepare_index_params(
|
||||
field_name='vector',
|
||||
index_type=MILVUS_INDEX_TYPE,
|
||||
metric_type=MILVUS_METRIC_TYPE,
|
||||
params=index_build_params,
|
||||
)
|
||||
|
||||
self.client.create_collection(collection_name=mt_collection_name, schema=schema)
|
||||
self.client.create_index(collection_name=mt_collection_name, index_params=vector_index)
|
||||
try:
|
||||
# A Milvus server auto-selects the scalar index type from a parameterless call.
|
||||
self.client.create_index(
|
||||
collection_name=mt_collection_name,
|
||||
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD),
|
||||
)
|
||||
except MilvusException:
|
||||
try:
|
||||
self.client.create_index(
|
||||
collection_name=mt_collection_name,
|
||||
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD, index_type='INVERTED'),
|
||||
)
|
||||
except MilvusException as e:
|
||||
# The index only accelerates resource_id filters; never fail
|
||||
# collection creation over it.
|
||||
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
|
||||
log.info(f'Created shared collection: {mt_collection_name}')
|
||||
return collection
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not utility.has_collection(mt_collection_name):
|
||||
if not self.client.has_collection(mt_collection_name):
|
||||
self._create_shared_collection(mt_collection_name, dimension)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return False
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
|
||||
self.client.load_collection(mt_collection)
|
||||
res = self.client.query(
|
||||
collection_name=mt_collection,
|
||||
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
output_fields=['id'],
|
||||
limit=1,
|
||||
)
|
||||
return len(res) > 0
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||
|
|
@ -172,7 +174,6 @@ class MilvusClient(VectorDBBase):
|
|||
_validate_resource_id(resource_id)
|
||||
dimension = len(items[0]['vector'])
|
||||
self._ensure_collection(mt_collection, dimension)
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
entities = []
|
||||
for item in items:
|
||||
|
|
@ -195,7 +196,7 @@ class MilvusClient(VectorDBBase):
|
|||
)
|
||||
|
||||
try:
|
||||
collection.insert(entities)
|
||||
self.client.insert(collection_name=mt_collection, data=entities)
|
||||
except MilvusException as e:
|
||||
log.error(
|
||||
f'Milvus insert failed (collection={mt_collection}, '
|
||||
|
|
@ -215,19 +216,18 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
self.client.load_collection(mt_collection)
|
||||
|
||||
search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}}
|
||||
results = collection.search(
|
||||
results = self.client.search(
|
||||
collection_name=mt_collection,
|
||||
data=vectors,
|
||||
anns_field='vector',
|
||||
param=search_params,
|
||||
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
|
||||
limit=limit,
|
||||
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
output_fields=['id', 'text', 'metadata'],
|
||||
)
|
||||
|
||||
|
|
@ -235,10 +235,11 @@ class MilvusClient(VectorDBBase):
|
|||
for hits in results:
|
||||
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
|
||||
for hit in hits:
|
||||
batch_ids.append(hit.entity.get('id'))
|
||||
batch_docs.append(hit.entity.get('text'))
|
||||
batch_metadatas.append(hit.entity.get('metadata'))
|
||||
batch_dists.append(hit.distance)
|
||||
entity = hit.get('entity', {})
|
||||
batch_ids.append(entity.get('id'))
|
||||
batch_docs.append(entity.get('text'))
|
||||
batch_metadatas.append(entity.get('metadata'))
|
||||
batch_dists.append(hit.get('distance'))
|
||||
ids.append(batch_ids)
|
||||
documents.append(batch_docs)
|
||||
metadatas.append(batch_metadatas)
|
||||
|
|
@ -254,11 +255,9 @@ class MilvusClient(VectorDBBase):
|
|||
):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if ids:
|
||||
# Milvus expects a string list for 'in' operator
|
||||
|
|
@ -270,30 +269,28 @@ class MilvusClient(VectorDBBase):
|
|||
_validate_metadata_key(key)
|
||||
expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'")
|
||||
|
||||
collection.delete(' and '.join(expr))
|
||||
self.client.delete(collection_name=mt_collection, filter=' and '.join(expr))
|
||||
|
||||
def reset(self):
|
||||
for collection_name in self.shared_collections:
|
||||
if utility.has_collection(collection_name):
|
||||
utility.drop_collection(collection_name)
|
||||
if self.client.has_collection(collection_name):
|
||||
self.client.drop_collection(collection_name)
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||
self.client.delete(collection_name=mt_collection, filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||
|
||||
def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
self.client.load_collection(mt_collection)
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if filter:
|
||||
|
|
@ -308,8 +305,9 @@ class MilvusClient(VectorDBBase):
|
|||
else:
|
||||
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
|
||||
|
||||
iterator = collection.query_iterator(
|
||||
expr=' and '.join(expr),
|
||||
iterator = self.client.query_iterator(
|
||||
collection_name=mt_collection,
|
||||
filter=' and '.join(expr),
|
||||
output_fields=['id', 'text', 'metadata'],
|
||||
limit=limit if limit else -1,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import validators
|
||||
from open_webui.retrieval.web.utils import resolve_hostname
|
||||
from open_webui.utils.misc import is_host_allowed
|
||||
from open_webui.utils.misc import get_allow_block_lists, is_host_allowed
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
|
@ -12,6 +13,16 @@ def get_filtered_results(results, filter_list):
|
|||
if not filter_list:
|
||||
return results
|
||||
|
||||
allow_list, block_list = get_allow_block_lists(filter_list)
|
||||
resolve_ips = False
|
||||
for entry in allow_list + block_list:
|
||||
try:
|
||||
ipaddress.ip_address(entry)
|
||||
except ValueError:
|
||||
continue
|
||||
resolve_ips = True
|
||||
break
|
||||
|
||||
filtered_results = []
|
||||
|
||||
for result in results:
|
||||
|
|
@ -25,12 +36,13 @@ def get_filtered_results(results, filter_list):
|
|||
|
||||
hostnames = [domain]
|
||||
|
||||
try:
|
||||
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
|
||||
hostnames.extend(ipv4_addresses)
|
||||
hostnames.extend(ipv6_addresses)
|
||||
except Exception:
|
||||
pass
|
||||
if resolve_ips:
|
||||
try:
|
||||
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
|
||||
hostnames.extend(ipv4_addresses)
|
||||
hostnames.extend(ipv6_addresses)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_host_allowed(hostnames, filter_list):
|
||||
filtered_results.append(result)
|
||||
|
|
|
|||
45
backend/open_webui/retrieval/web/openserp.py
Normal file
45
backend/open_webui/retrieval/web/openserp.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_openserp(
|
||||
base_url: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query an OpenSERP instance and return normalised results.
|
||||
|
||||
OpenSERP aggregates results from 6 engines (google, bing, yandex,
|
||||
baidu, duckduckgo, ecosia) at once via ``/mega/search``.
|
||||
|
||||
No API key is required -- only a reachable OpenSERP base URL.
|
||||
"""
|
||||
url = f'{base_url.rstrip("/")}/mega/search'
|
||||
params = {'text': query, 'limit': count}
|
||||
|
||||
log.debug('searching OpenSERP at %s', url)
|
||||
|
||||
session = await get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
response.raise_for_status()
|
||||
payload = await response.json()
|
||||
|
||||
results = payload.get('results', [])
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=item.get('url', ''),
|
||||
title=item.get('title'),
|
||||
snippet=item.get('snippet'),
|
||||
)
|
||||
for item in results[:count]
|
||||
]
|
||||
|
|
@ -3,9 +3,10 @@ import ipaddress
|
|||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, time, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
|
|
@ -74,6 +75,34 @@ def resolve_hostname(hostname):
|
|||
return ipv4_addresses, ipv6_addresses
|
||||
|
||||
|
||||
def _is_global_addr(ip: str) -> bool:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if not addr.is_global:
|
||||
return False
|
||||
if not isinstance(addr, ipaddress.IPv6Address):
|
||||
return True
|
||||
|
||||
embedded = []
|
||||
if addr.ipv4_mapped:
|
||||
embedded.append(addr.ipv4_mapped)
|
||||
if addr.sixtofour:
|
||||
embedded.append(addr.sixtofour)
|
||||
if addr.teredo:
|
||||
embedded.extend(addr.teredo)
|
||||
|
||||
b = addr.packed
|
||||
if b[:12] == b"\x00" * 12:
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:12] == b"\x00\x64\xff\x9b" + b"\x00" * 8:
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:6] == b"\x00\x64\xff\x9b\x00\x01":
|
||||
if b[8] != 0:
|
||||
return False
|
||||
embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10]))))
|
||||
|
||||
return all(ip.is_global for ip in embedded)
|
||||
|
||||
|
||||
def validate_url(url: Union[str, Sequence[str]]):
|
||||
if isinstance(url, str):
|
||||
if isinstance(validators.url(url), validators.ValidationError):
|
||||
|
|
@ -110,8 +139,7 @@ def validate_url(url: Union[str, Sequence[str]]):
|
|||
# Check if any of the resolved addresses are private
|
||||
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter
|
||||
for ip in ipv4_addresses + ipv6_addresses:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if not addr.is_global:
|
||||
if not _is_global_addr(ip):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
return True
|
||||
elif isinstance(url, Sequence):
|
||||
|
|
@ -146,7 +174,7 @@ def _ssrf_safe_new_conn(self):
|
|||
raise OSError(f'getaddrinfo for {host!r} returned empty list')
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for _, _, _, _, sa in infos:
|
||||
if not ipaddress.ip_address(sa[0]).is_global:
|
||||
if not _is_global_addr(sa[0]):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
err = None
|
||||
for fam, typ, proto, _, sa in infos:
|
||||
|
|
@ -158,6 +186,11 @@ def _ssrf_safe_new_conn(self):
|
|||
if getattr(self, 'source_address', None):
|
||||
sock.bind(self.source_address)
|
||||
for opt in getattr(self, 'socket_options', None) or ():
|
||||
if len(opt) == 4 and isinstance(opt[3], str):
|
||||
# urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp")
|
||||
if opt[3].lower() == 'tcp':
|
||||
sock.setsockopt(*opt[:3])
|
||||
continue
|
||||
sock.setsockopt(*opt)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
|
|
@ -202,7 +235,7 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
|
|||
results = await super().resolve(host, port, family)
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for entry in results:
|
||||
if not ipaddress.ip_address(entry['host']).is_global:
|
||||
if not _is_global_addr(entry['host']):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
return results
|
||||
|
||||
|
|
@ -600,57 +633,63 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
def _intercept_navigation_sync(self, route, request=None):
|
||||
req = request or route.request
|
||||
|
||||
if req.resource_type != 'document':
|
||||
route.continue_()
|
||||
return
|
||||
|
||||
try:
|
||||
validate_url(req.url)
|
||||
resp = route.fetch(max_redirects=0)
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
for _ in range(20):
|
||||
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
location = resp.headers.get('location')
|
||||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
validate_url(url)
|
||||
resp = route.fetch(url=url, max_redirects=0)
|
||||
if not 300 <= resp.status < 400:
|
||||
break
|
||||
else:
|
||||
route.abort()
|
||||
return
|
||||
except Exception:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
resp = route.fetch()
|
||||
else:
|
||||
try:
|
||||
resp = route.fetch(max_redirects=0)
|
||||
except TypeError:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
route.fulfill(response=resp)
|
||||
|
||||
async def _intercept_navigation(self, route, request=None):
|
||||
req = request or route.request
|
||||
|
||||
if req.resource_type != 'document':
|
||||
await route.continue_()
|
||||
return
|
||||
|
||||
try:
|
||||
await run_in_threadpool(validate_url, req.url)
|
||||
resp = await route.fetch(max_redirects=0)
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
for _ in range(20):
|
||||
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
location = resp.headers.get('location')
|
||||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
await run_in_threadpool(validate_url, url)
|
||||
resp = await route.fetch(url=url, max_redirects=0)
|
||||
if not 300 <= resp.status < 400:
|
||||
break
|
||||
else:
|
||||
await route.abort()
|
||||
return
|
||||
except Exception:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
resp = await route.fetch()
|
||||
else:
|
||||
try:
|
||||
resp = await route.fetch(max_redirects=0)
|
||||
except TypeError:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
await route.fulfill(response=resp)
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
|
|
@ -664,24 +703,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
else:
|
||||
browser = p.chromium.launch(headless=self.headless, proxy=self.proxy)
|
||||
|
||||
for url in self.urls:
|
||||
try:
|
||||
self._safe_process_url_sync(url)
|
||||
page = browser.new_page()
|
||||
page.route('**/*', self._intercept_navigation_sync)
|
||||
response = page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
with browser:
|
||||
for url in self.urls:
|
||||
try:
|
||||
self._safe_process_url_sync(url)
|
||||
with browser.new_page(service_workers='block') as page:
|
||||
page.route('**/*', self._intercept_navigation_sync)
|
||||
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
|
||||
text = self.evaluator.evaluate(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
browser.close()
|
||||
text = self.evaluator.evaluate(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""Safely load URLs asynchronously with support for remote browser."""
|
||||
|
|
@ -694,24 +734,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
|||
else:
|
||||
browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy)
|
||||
|
||||
for url in self.urls:
|
||||
try:
|
||||
await self._safe_process_url(url)
|
||||
page = await browser.new_page()
|
||||
await page.route('**/*', self._intercept_navigation)
|
||||
response = await page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
async with browser:
|
||||
for url in self.urls:
|
||||
try:
|
||||
await self._safe_process_url(url)
|
||||
async with await browser.new_page(service_workers='block') as page:
|
||||
await page.route('**/*', self._intercept_navigation)
|
||||
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = await page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
|
||||
text = await self.evaluator.evaluate_async(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
await browser.close()
|
||||
text = await self.evaluator.evaluate_async(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
|
||||
|
||||
class SafeWebBaseLoader(WebBaseLoader):
|
||||
|
|
@ -723,6 +764,8 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||
trust_env (bool, optional): set to True if using proxy to make web requests, for example
|
||||
using http(s)_proxy environment variables. Defaults to False.
|
||||
"""
|
||||
# lxml parses scraped pages far faster than the html.parser default
|
||||
kwargs.setdefault('default_parser', 'lxml')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trust_env = trust_env
|
||||
|
||||
|
|
@ -785,20 +828,13 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
url = urls[i]
|
||||
if parser is None:
|
||||
if url.endswith('.xml'):
|
||||
parser = 'xml'
|
||||
else:
|
||||
parser = self.default_parser
|
||||
self._check_parser(parser)
|
||||
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
|
||||
url_parser = parser
|
||||
if url_parser is None:
|
||||
url_parser = 'xml' if url.endswith('.xml') else self.default_parser
|
||||
self._check_parser(url_parser)
|
||||
final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs))
|
||||
return final_results
|
||||
|
||||
async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]:
|
||||
"""Async fetch all urls, then return soups for all results."""
|
||||
results = await self.fetch_all(urls)
|
||||
return self._unpack_fetch_results(results, urls, parser=parser)
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""Lazy load text from the url(s) in web_path with error handling."""
|
||||
for path in self.web_paths:
|
||||
|
|
@ -814,19 +850,20 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||
# Log the error and continue with the next URL
|
||||
log.exception(f'Error loading {path}: {e}')
|
||||
|
||||
def _document_from_html(self, html: str, url: str) -> Document:
|
||||
"""Build one Document."""
|
||||
soup = self._unpack_fetch_results([html], [url])[0]
|
||||
return Document(
|
||||
page_content=soup.get_text(**self.bs_get_text_kwargs),
|
||||
metadata=extract_metadata(soup, url),
|
||||
)
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""Async lazy load text from the url(s) in web_path."""
|
||||
results = await self.ascrape_all(self.web_paths)
|
||||
for path, soup in zip(self.web_paths, results):
|
||||
text = soup.get_text(**self.bs_get_text_kwargs)
|
||||
metadata = {'source': path}
|
||||
if title := soup.find('title'):
|
||||
metadata['title'] = title.get_text()
|
||||
if description := soup.find('meta', attrs={'name': 'description'}):
|
||||
metadata['description'] = description.get('content', 'No description found.')
|
||||
if html := soup.find('html'):
|
||||
metadata['language'] = html.get('lang', 'No language found.')
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
results = await self.fetch_all(self.web_paths)
|
||||
for path, html in zip(self.web_paths, results):
|
||||
# parsing a large page costs hundreds of ms, keep it off the event loop
|
||||
yield await asyncio.to_thread(self._document_from_html, html, path)
|
||||
|
||||
async def aload(self) -> list[Document]:
|
||||
"""Load data into Document objects."""
|
||||
|
|
@ -838,6 +875,7 @@ def get_web_loader(
|
|||
verify_ssl: bool = True,
|
||||
requests_per_second: int = 2,
|
||||
trust_env: bool = False,
|
||||
loader_config: Optional[dict] = None,
|
||||
):
|
||||
# Check if the URLs are valid
|
||||
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
|
||||
|
|
@ -846,6 +884,16 @@ def get_web_loader(
|
|||
log.warning(f'All provided URLs were blocked or invalid: {urls}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
loader_config = loader_config or {}
|
||||
|
||||
def cfg(key, env_value):
|
||||
# Admin-saved DB value wins; env constant covers keys never saved.
|
||||
value = loader_config.get(key)
|
||||
return env_value if value is None else value
|
||||
|
||||
engine = cfg('web_loader_engine', WEB_LOADER_ENGINE)
|
||||
web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT)
|
||||
|
||||
web_loader_args = {
|
||||
'web_paths': safe_urls,
|
||||
'verify_ssl': verify_ssl,
|
||||
|
|
@ -854,13 +902,15 @@ def get_web_loader(
|
|||
'trust_env': trust_env,
|
||||
}
|
||||
|
||||
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
|
||||
WebLoaderClass = None
|
||||
|
||||
if engine == '' or engine == 'safe_web':
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
|
||||
request_kwargs = {}
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
timeout_value = float(WEB_LOADER_TIMEOUT)
|
||||
timeout_value = float(web_loader_timeout)
|
||||
except ValueError:
|
||||
timeout_value = None
|
||||
|
||||
|
|
@ -870,42 +920,44 @@ def get_web_loader(
|
|||
if request_kwargs:
|
||||
web_loader_args['requests_kwargs'] = request_kwargs
|
||||
|
||||
if WEB_LOADER_ENGINE == 'playwright':
|
||||
if engine == 'playwright':
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
|
||||
if PLAYWRIGHT_WS_URL:
|
||||
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
|
||||
web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT)
|
||||
playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL)
|
||||
if playwright_ws_url:
|
||||
web_loader_args['playwright_ws_url'] = playwright_ws_url
|
||||
|
||||
if WEB_LOADER_ENGINE == 'firecrawl':
|
||||
if engine == 'firecrawl':
|
||||
WebLoaderClass = SafeFireCrawlLoader
|
||||
web_loader_args['api_key'] = FIRECRAWL_API_KEY
|
||||
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
|
||||
if FIRECRAWL_TIMEOUT:
|
||||
web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY)
|
||||
web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL)
|
||||
firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT)
|
||||
if firecrawl_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(firecrawl_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'tavily':
|
||||
if engine == 'tavily':
|
||||
WebLoaderClass = SafeTavilyLoader
|
||||
web_loader_args['api_key'] = TAVILY_API_KEY
|
||||
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
|
||||
web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY)
|
||||
web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH)
|
||||
|
||||
if WEB_LOADER_ENGINE == 'microsoft_web_iq':
|
||||
if engine == 'microsoft_web_iq':
|
||||
WebLoaderClass = SafeMicrosoftWebIQLoader
|
||||
web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL
|
||||
web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY
|
||||
web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL)
|
||||
web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY)
|
||||
web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE)
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(web_loader_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'external':
|
||||
if engine == 'external':
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
|
||||
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
|
||||
web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL)
|
||||
web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY)
|
||||
|
||||
if WebLoaderClass:
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
|
|
@ -919,6 +971,6 @@ def get_web_loader(
|
|||
return web_loader
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
|
||||
f'Invalid WEB_LOADER_ENGINE: {engine}. '
|
||||
"Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_PYDUB_PREPROCESSING,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
|
|
@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
|||
for key, value in payload.items():
|
||||
form_data.add_field(key, str(value))
|
||||
|
||||
with open(file_path, 'rb') as audio_file:
|
||||
form_data.add_field('file', audio_file, filename=filename)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
form_data.add_field('file', audio_chunks(), filename=filename)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
if r.status == 200:
|
||||
break
|
||||
|
||||
|
|
@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
|||
base_url or f'https://{region}.api.cognitive.microsoft.com'
|
||||
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
|
||||
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
|
||||
|
||||
r = None
|
||||
try:
|
||||
session = await get_session()
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('audio', audio_chunks(), filename=filename)
|
||||
r = await session.post(
|
||||
url=endpoint,
|
||||
data=form_data,
|
||||
|
|
@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
if language:
|
||||
form_data.add_field('language', language)
|
||||
|
||||
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
|
|
@ -1076,25 +1091,23 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
|
|||
detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'),
|
||||
)
|
||||
|
||||
results = []
|
||||
try:
|
||||
tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
try:
|
||||
results.append(await coro)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as transcribe_exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Error transcribing chunk: {transcribe_exc}',
|
||||
)
|
||||
# gather keeps results in chunk order, unlike as_completed
|
||||
results = await asyncio.gather(*tasks)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as transcribe_exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Error transcribing chunk: {transcribe_exc}',
|
||||
)
|
||||
finally:
|
||||
# Clean up only the temporary chunks, never the original file
|
||||
for chunk_path in chunk_paths:
|
||||
if chunk_path != file_path and os.path.isfile(chunk_path):
|
||||
try:
|
||||
os.remove(chunk_path)
|
||||
await asyncio.to_thread(os.remove, chunk_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1208,12 +1221,8 @@ async def transcription(
|
|||
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
|
||||
raise ValueError('Invalid file path detected')
|
||||
|
||||
def _write_upload():
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(contents)
|
||||
|
||||
# Audio uploads can be large; write to disk off the event loop.
|
||||
await asyncio.to_thread(_write_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
try:
|
||||
metadata = None
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||
from fastapi.responses import JSONResponse, Response
|
||||
from ldap3 import NONE, Connection, Server, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.utils.dn import parse_dn
|
||||
from open_webui.config import (
|
||||
ENABLE_PASSWORD_AUTH,
|
||||
OAUTH_PROVIDERS,
|
||||
|
|
@ -24,6 +25,8 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE,
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
|
||||
WEBUI_AUTH,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
|
|
@ -83,6 +86,17 @@ log = logging.getLogger(__name__)
|
|||
# Forgive us our failed attempts, as we forgive those
|
||||
# who exceed their allotted rate against this gate.
|
||||
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
|
||||
# Best-effort throttle only: there is no caller identity before the provider answers,
|
||||
# and deployments may derive request.client from proxy headers.
|
||||
token_exchange_rate_limiter = (
|
||||
RateLimiter(
|
||||
redis_client=get_redis_client(),
|
||||
limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
|
||||
window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
|
||||
)
|
||||
if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ADMIN_CONFIG_KEYS = {
|
||||
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
|
||||
|
|
@ -128,6 +142,9 @@ LDAP_SERVER_CONFIG_KEYS = {
|
|||
'certificate_path': 'ldap.server.ca_cert_file',
|
||||
'validate_cert': 'ldap.server.validate_cert',
|
||||
'ciphers': 'ldap.server.ciphers',
|
||||
'enable_group_management': 'ldap.group.enable_management',
|
||||
'enable_group_creation': 'ldap.group.enable_creation',
|
||||
'attribute_for_groups': 'ldap.server.attribute_for_groups',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -398,6 +415,52 @@ async def update_password(
|
|||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
|
||||
def _unescape_ldap_dn_value(value: str) -> str:
|
||||
"""Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``.
|
||||
|
||||
Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together.
|
||||
"""
|
||||
hexdigits = '0123456789abcdefABCDEF'
|
||||
result = []
|
||||
pos = 0
|
||||
length = len(value)
|
||||
while pos < length:
|
||||
char = value[pos]
|
||||
if char == '\\' and pos + 1 < length:
|
||||
if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits:
|
||||
byte_values = bytearray()
|
||||
while (
|
||||
pos + 2 < length
|
||||
and value[pos] == '\\'
|
||||
and value[pos + 1] in hexdigits
|
||||
and value[pos + 2] in hexdigits
|
||||
):
|
||||
byte_values.append(int(value[pos + 1 : pos + 3], 16))
|
||||
pos += 3
|
||||
result.append(byte_values.decode('utf-8', errors='replace'))
|
||||
else:
|
||||
# Backslash escaping a literal special char, e.g. "\," or "\+".
|
||||
result.append(value[pos + 1])
|
||||
pos += 2
|
||||
else:
|
||||
result.append(char)
|
||||
pos += 1
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def extract_group_cn_from_dn(group_dn: str) -> str | None:
|
||||
"""Return the first CN component of an LDAP group DN, or None.
|
||||
|
||||
Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose
|
||||
name contains a comma) are handled correctly instead of naively splitting
|
||||
on ``,``.
|
||||
"""
|
||||
for attr_type, attr_value, _ in parse_dn(group_dn):
|
||||
if attr_type.upper() == 'CN':
|
||||
return _unescape_ldap_dn_value(attr_value)
|
||||
return None
|
||||
|
||||
|
||||
############################
|
||||
# LDAP Authentication
|
||||
############################
|
||||
|
|
@ -545,17 +608,10 @@ async def ldap_auth(
|
|||
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
|
||||
|
||||
try:
|
||||
group_cn = None
|
||||
|
||||
for item in group_dn.split(','):
|
||||
item = item.strip()
|
||||
if item.upper().startswith('CN='):
|
||||
group_cn = item[3:]
|
||||
break
|
||||
group_cn = extract_group_cn_from_dn(group_dn)
|
||||
|
||||
if group_cn:
|
||||
user_groups.append(group_cn)
|
||||
|
||||
else:
|
||||
log.warning(f'Could not extract CN from group DN: {group_dn}')
|
||||
except Exception as e:
|
||||
|
|
@ -627,9 +683,9 @@ async def ldap_auth(
|
|||
|
||||
if user:
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
|
||||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
||||
try:
|
||||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
||||
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
|
||||
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
|
||||
except Exception as e:
|
||||
|
|
@ -1188,6 +1244,9 @@ class LdapServerConfig(BaseModel):
|
|||
certificate_path: str | None = None
|
||||
validate_cert: bool = True
|
||||
ciphers: str | None = 'ALL'
|
||||
enable_group_management: bool = False
|
||||
enable_group_creation: bool = False
|
||||
attribute_for_groups: str = 'memberOf'
|
||||
|
||||
|
||||
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
|
||||
|
|
@ -1209,6 +1268,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
|
|||
if not value:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
|
||||
|
||||
# The group attribute is what group management reads from the directory
|
||||
# entry; an empty value would make group sync silently do nothing.
|
||||
if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip():
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups'))
|
||||
|
||||
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 ''
|
||||
|
|
@ -1240,6 +1304,7 @@ class OAuthConfigForm(BaseModel):
|
|||
"""All OAuth/OIDC settings exposed to the admin panel."""
|
||||
|
||||
# General OAuth
|
||||
ENABLE_OAUTH: bool | None = None
|
||||
ENABLE_OAUTH_SIGNUP: bool | None = None
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
|
||||
OAUTH_AUTO_REDIRECT: bool | None = None
|
||||
|
|
@ -1295,6 +1360,7 @@ OAUTH_COMMA_LIST_FIELDS = {
|
|||
|
||||
|
||||
OAUTH_CONFIG_KEYS = {
|
||||
'ENABLE_OAUTH': 'oauth.enable',
|
||||
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
|
||||
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
|
||||
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
|
||||
|
|
@ -1467,6 +1533,14 @@ async def token_exchange(
|
|||
detail='Token exchange is disabled',
|
||||
)
|
||||
|
||||
if token_exchange_rate_limiter and token_exchange_rate_limiter.is_limited(
|
||||
request.client.host if request.client else 'unknown'
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
||||
)
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
# Check if provider is configured
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from open_webui.models.automations import (
|
|||
Automations,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
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 (
|
||||
|
|
@ -56,16 +57,11 @@ async def check_automations_permission(request, user):
|
|||
|
||||
|
||||
def check_automation_access(automation, user):
|
||||
if not automation:
|
||||
if not automation or user.id != automation.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if user.role != 'admin' and user.id != automation.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False):
|
||||
|
|
@ -97,6 +93,17 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
|||
)
|
||||
|
||||
|
||||
async def check_automation_folder_access(folder_id: Optional[str], user, db: AsyncSession):
|
||||
if folder_id is None:
|
||||
return
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id, db=db)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
|
||||
"""Full enrichment for single-item views (includes next_runs computation)."""
|
||||
last_run = await AutomationRuns.get_latest(automation.id, db=db)
|
||||
|
|
@ -117,6 +124,7 @@ async def get_automation_items(
|
|||
request: Request,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -130,6 +138,7 @@ async def get_automation_items(
|
|||
user_id=user.id,
|
||||
query=query,
|
||||
status=status,
|
||||
folder_id=folder_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
|
|
@ -164,6 +173,7 @@ async def create_new_automation(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_automations_permission(request, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
|
|
@ -182,7 +192,7 @@ async def create_new_automation(
|
|||
EVENTS.AUTOMATION_CREATED,
|
||||
actor=user,
|
||||
subject_id=automation.id,
|
||||
data={'name': automation.name, 'is_active': automation.is_active},
|
||||
data={'name': automation.name, 'is_active': automation.is_active, 'folder_id': automation.folder_id},
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -221,6 +231,7 @@ async def update_automation_by_id(
|
|||
await check_automations_permission(request, user)
|
||||
automation = await Automations.get_by_id(id, db=db)
|
||||
check_automation_access(automation, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
|
|
@ -240,7 +251,7 @@ async def update_automation_by_id(
|
|||
EVENTS.AUTOMATION_UPDATED,
|
||||
actor=user,
|
||||
subject_id=updated.id,
|
||||
data={'name': updated.name, 'is_active': updated.is_active},
|
||||
data={'name': updated.name, 'is_active': updated.is_active, 'folder_id': updated.folder_id},
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ from open_webui.utils.models import (
|
|||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -915,7 +914,6 @@ 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 = await Config.get('webui.url')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
|
|
@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
|
|||
|
||||
# Batch fetch channel members in 1 query (fixes N+1)
|
||||
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
|
||||
url = f'{webui_url}/channels/{channel.id}'
|
||||
|
||||
for u in users:
|
||||
if (u.id not in active_user_ids) and u.id in member_ids:
|
||||
if enable_user_webhooks and u.settings:
|
||||
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
name,
|
||||
webhook_url,
|
||||
f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}',
|
||||
{
|
||||
'action': 'channel',
|
||||
'message': message.content,
|
||||
'title': channel.name,
|
||||
'url': f'{webui_url}/channels/{channel.id}',
|
||||
},
|
||||
)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHANNEL_MESSAGE,
|
||||
subject_id=channel.id,
|
||||
subject_type='channel',
|
||||
data={
|
||||
'user_id': u.id,
|
||||
'channel_id': channel.id,
|
||||
'message_id': message.id,
|
||||
'sender_id': message.user_id,
|
||||
'content': message.content,
|
||||
'message': f'#{channel.name} - {url}\n\n{message.content}',
|
||||
'content_preview': message.content[:300],
|
||||
'title': channel.name,
|
||||
'url': url,
|
||||
},
|
||||
message=channel.name,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -1496,12 +1500,13 @@ async def update_message_by_id(
|
|||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id, channel, permission='write', strict=False, db=db
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member edits.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.update_message_by_id(message_id, form_data, db=db)
|
||||
|
|
@ -1721,18 +1726,17 @@ async def delete_message_by_id(
|
|||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member deletes.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.delete_message_by_id(message_id, db=db)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import logging
|
|||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
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.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import (
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
|
|
@ -26,6 +28,7 @@ from open_webui.models.chats import (
|
|||
ChatStatsExport,
|
||||
ChatTitleIdResponse,
|
||||
ChatUsageStatsListResponse,
|
||||
is_internal_chat,
|
||||
MessageStats,
|
||||
)
|
||||
from open_webui.models.folders import Folders
|
||||
|
|
@ -35,8 +38,9 @@ from open_webui.socket.main import get_event_emitter
|
|||
from open_webui.tasks import has_active_tasks, 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.context_compaction import compact_chat_branch
|
||||
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
|
||||
from open_webui.utils.chat_fork import build_fork_history
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -51,13 +55,89 @@ SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
|||
CHAT_CONFIG_KEYS = {
|
||||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
}
|
||||
|
||||
|
||||
async def get_optional_verified_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security),
|
||||
):
|
||||
try:
|
||||
user = await get_current_user(request, response, background_tasks, auth_token)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
if user.role not in {'user', 'admin'}:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def is_open_shared_chat(shared, db: AsyncSession) -> bool:
|
||||
return await AccessGrants.has_anyone_access(
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
|
||||
if user.role == 'pending':
|
||||
return False
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
return True
|
||||
if shared.user_id == user.id:
|
||||
return True
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def add_active_state_to_chat_list(
|
||||
request: Request, chat_list: list[ChatTitleIdResponse]
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
for chat in chat_list:
|
||||
chat.active = False
|
||||
if not await has_active_tasks(request.app.state.redis, chat.id):
|
||||
continue
|
||||
|
||||
chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
|
||||
user_folders = await Folders.get_folders_by_user_id(user_id, db=db)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in user_folders}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
|
||||
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
class ChatConfigForm(BaseModel):
|
||||
ENABLE_CONTEXT_COMPACTION: bool
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
|
||||
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
|
||||
|
||||
|
|
@ -104,7 +184,12 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
|
|||
|
||||
async def get_chat_config_values() -> dict:
|
||||
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
|
||||
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
|
||||
if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None:
|
||||
config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
|
||||
return config
|
||||
|
||||
|
||||
def chat_config_updates(data: dict) -> dict:
|
||||
|
|
@ -131,10 +216,13 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio
|
|||
@router.get('/', response_model=list[ChatTitleIdResponse])
|
||||
@router.get('/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_session_user_chat_list(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
page: int | None = None,
|
||||
include_pinned: bool | None = False,
|
||||
include_folders: bool | None = False,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
|
|
@ -142,26 +230,42 @@ async def get_session_user_chat_list(
|
|||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
@router.post('/read')
|
||||
async def mark_chats_read_by_user_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return {
|
||||
'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db),
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# GetChatUsageStats
|
||||
# EXPERIMENTAL: may be removed in future releases
|
||||
|
|
@ -599,6 +703,7 @@ async def delete_all_user_chats(
|
|||
|
||||
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_user_id(
|
||||
request: Request,
|
||||
user_id: str,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
|
|
@ -623,9 +728,10 @@ async def get_user_chat_list_by_user_id(
|
|||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_chat_list_by_user_id(
|
||||
chats = await Chats.get_chat_list_by_user_id(
|
||||
user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -664,7 +770,7 @@ async def create_new_chat(
|
|||
subject_id=chat.id,
|
||||
data={'title': chat.title, 'folder_id': chat.folder_id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
|
@ -712,11 +818,15 @@ async def get_chat_config(user=Depends(get_admin_user)):
|
|||
@router.post('/config', response_model=ChatConfigForm)
|
||||
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
|
||||
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
|
||||
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
|
||||
retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
|
||||
await Config.upsert(
|
||||
chat_config_updates(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -730,6 +840,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
|
|||
|
||||
@router.get('/search', response_model=list[ChatTitleIdResponse])
|
||||
async def search_user_chats(
|
||||
request: Request,
|
||||
text: str,
|
||||
page: int | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
|
|
@ -744,7 +855,17 @@ async def search_user_chats(
|
|||
search_text = chat_search_content_text(text)
|
||||
chat_list = []
|
||||
for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
|
||||
chat_list.append(ChatTitleIdResponse(**chat.model_dump(), snippet=chat_search_snippet(chat.chat, search_text)))
|
||||
# Explicit fields: model_dump() would deep-copy the entire chat blob per row
|
||||
chat_list.append(
|
||||
ChatTitleIdResponse(
|
||||
id=chat.id,
|
||||
title=chat.title,
|
||||
updated_at=chat.updated_at,
|
||||
created_at=chat.created_at,
|
||||
last_read_at=chat.last_read_at,
|
||||
snippet=chat_search_snippet(chat.chat, search_text),
|
||||
)
|
||||
)
|
||||
|
||||
# Delete tag if no chat is found
|
||||
words = text.strip().split(' ')
|
||||
|
|
@ -755,7 +876,7 @@ async def search_user_chats(
|
|||
log.debug(f'deleting tag: {tag_id}')
|
||||
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
|
||||
return chat_list
|
||||
return await add_active_state_to_chat_list(request, chat_list)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -773,15 +894,18 @@ async def get_chats_by_folder_id(
|
|||
folder_ids.extend([folder.id for folder in children_folders])
|
||||
|
||||
return [
|
||||
ChatResponse(**chat.model_dump())
|
||||
ChatResponse.model_validate(chat, from_attributes=True)
|
||||
for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db)
|
||||
]
|
||||
|
||||
|
||||
@router.get('/folder/{folder_id}/list')
|
||||
@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_chat_list_by_folder_id(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
page: int | None = 1,
|
||||
sort_by: str = 'unread_updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -789,11 +913,16 @@ async def get_chat_list_by_folder_id(
|
|||
limit = 10
|
||||
skip = (page - 1) * limit
|
||||
|
||||
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
|
||||
return [
|
||||
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
|
||||
for chat in chats
|
||||
]
|
||||
chats = await Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder_id,
|
||||
user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -806,8 +935,11 @@ async def get_chat_list_by_folder_id(
|
|||
|
||||
|
||||
@router.get('/pinned', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
async def get_user_pinned_chats(
|
||||
request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -838,7 +970,7 @@ async def generate_chat_export_ndjson(user_id: str):
|
|||
|
||||
for chat in result.items:
|
||||
try:
|
||||
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
|
||||
yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n'
|
||||
except Exception as e:
|
||||
log.exception(f'Error serializing chat {chat.id}: {e}')
|
||||
|
||||
|
|
@ -863,7 +995,10 @@ async def get_user_chats(user=Depends(get_verified_user)):
|
|||
|
||||
@router.get('/all/archived', response_model=list[ChatResponse])
|
||||
async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)]
|
||||
return [
|
||||
ChatResponse.model_validate(chat, from_attributes=True)
|
||||
for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -890,7 +1025,7 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession =
|
|||
async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_ADMIN_EXPORT:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)]
|
||||
return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)]
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -900,6 +1035,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio
|
|||
|
||||
@router.get('/archived', response_model=list[ChatTitleIdResponse])
|
||||
async def get_archived_session_user_chat_list(
|
||||
request: Request,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
order_by: str | None = None,
|
||||
|
|
@ -921,13 +1057,14 @@ async def get_archived_session_user_chat_list(
|
|||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_archived_chat_list_by_user_id(
|
||||
chats = await Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1045,38 +1182,30 @@ async def get_shared_session_user_chat_list(
|
|||
|
||||
@router.get('/share/{share_id}', response_model=ChatResponse | None)
|
||||
async def get_shared_chat_by_id(
|
||||
share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if user.role == 'pending':
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared:
|
||||
if await is_open_shared_chat(shared, db=db) or (
|
||||
user is not None and await can_read_shared_chat(user, shared, db=db)
|
||||
):
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
if chat:
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
# Fallback: admins can also access any chat directly by chat ID
|
||||
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = None
|
||||
if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(share_id, db=db)
|
||||
if chat:
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
# Look up the original chat_id to check access grants (admins bypass)
|
||||
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if not has_grant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1095,6 +1224,7 @@ class TagFilterForm(TagForm):
|
|||
|
||||
@router.post('/tags', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_tag_name(
|
||||
request: Request,
|
||||
form_data: TagFilterForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -1105,7 +1235,7 @@ async def get_user_chat_list_by_tag_name(
|
|||
if len(chats) == 0:
|
||||
await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
|
||||
|
||||
return chats
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1136,7 +1266,8 @@ async def compact_chat_by_id(
|
|||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id)
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
|
||||
current_message_id = chat.current_message_id or history.get('currentId')
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
|
||||
model_id = (form_data.model if form_data else None) or next(
|
||||
(message.get('model') for message in reversed(message_list) if message.get('model')),
|
||||
None,
|
||||
|
|
@ -1149,6 +1280,7 @@ async def compact_chat_by_id(
|
|||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
|
||||
|
||||
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
|
||||
result['context_usage'] = await get_chat_context_usage(chat, model_id)
|
||||
if result.get('compacted'):
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -1169,31 +1301,36 @@ async def compact_chat_by_id(
|
|||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not chat:
|
||||
# Check if user has access via access grants (shared_chat grants)
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat and user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
|
||||
# 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
|
||||
# Access explicitly granted to this user applies to admins too, so an admin
|
||||
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
|
||||
if not chat:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
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())
|
||||
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
|
@ -1220,7 +1357,18 @@ async def update_chat_by_id(
|
|||
form_data.chat.get('history'),
|
||||
)
|
||||
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
|
||||
touch = 'history' in form_data.chat or 'messages' in form_data.chat
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch)
|
||||
if form_data.variables is not None:
|
||||
chat = (
|
||||
await Chats.update_chat_variables_by_id(
|
||||
id,
|
||||
form_data.variables,
|
||||
db=db,
|
||||
touch=False,
|
||||
)
|
||||
or chat
|
||||
)
|
||||
|
||||
# Reconcile chat_message rows without inferring deletes from missing IDs.
|
||||
# Message deletion has its own endpoint below.
|
||||
|
|
@ -1235,7 +1383,7 @@ async def update_chat_by_id(
|
|||
subject_id=id,
|
||||
data={'title': chat.title},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1309,7 +1457,7 @@ async def update_chat_message_by_id(
|
|||
subject_id=message_id,
|
||||
data={'chat_id': id, 'content_preview': form_data.content[:300]},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None)
|
||||
|
|
@ -1348,7 +1496,7 @@ async def delete_chat_message_by_id(
|
|||
subject_id=message_id,
|
||||
data={'chat_id': id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1419,55 +1567,48 @@ async def delete_chat_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
# Cancel any in-flight LLM tasks (streaming, title/tags generation)
|
||||
# before deleting the chat to prevent orphaned requests.
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
|
||||
# Authorize before any side effect: cancelling a chat's in-flight tasks must
|
||||
# not be reachable for a chat the caller may not delete.
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
result = await Chats.delete_chat_by_id(id, db=db)
|
||||
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': chat.user_id},
|
||||
)
|
||||
return result
|
||||
else:
|
||||
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,
|
||||
)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Cancel any in-flight LLM tasks (streaming, title/tags generation) before
|
||||
# deleting the chat to prevent orphaned requests.
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
# Cascade to internal child chats spawned from this one.
|
||||
for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id):
|
||||
await stop_item_tasks(request.app.state.redis, child_id)
|
||||
await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id)
|
||||
|
||||
if user.role == 'admin':
|
||||
result = await Chats.delete_chat_by_id(id, db=db)
|
||||
else:
|
||||
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': user.id},
|
||||
)
|
||||
return result
|
||||
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': chat.user_id},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1519,6 +1660,101 @@ class CloneForm(BaseModel):
|
|||
title: str | None = None
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/fork', response_model=ChatResponse | None)
|
||||
async def fork_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ForkForm | None = None,
|
||||
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 not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
|
||||
if any(
|
||||
message.get('role') == 'assistant' and message.get('done') is False
|
||||
for message in messages_map.values()
|
||||
if isinstance(message, dict)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
source_message_id = (
|
||||
(form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
|
||||
)
|
||||
if not source_message_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
|
||||
|
||||
try:
|
||||
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
updated_chat = {**(chat.chat or {})}
|
||||
updated_chat.pop('currentId', None)
|
||||
updated_chat.update(
|
||||
{
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': source_message_id,
|
||||
'title': f'{chat.title} (fork)',
|
||||
'history': fork_history,
|
||||
'messages': fork_messages,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
**(chat.meta or {}),
|
||||
'forked_from': chat.id,
|
||||
'forked_from_message_id': source_message_id,
|
||||
}
|
||||
|
||||
fork = await Chats.insert_new_chat(
|
||||
str(uuid4()),
|
||||
user.id,
|
||||
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
|
||||
db=db,
|
||||
internal_meta=meta,
|
||||
)
|
||||
|
||||
if fork and chat.variables:
|
||||
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
|
||||
|
||||
if not fork:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if chat.pinned:
|
||||
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_CLONED,
|
||||
actor=user,
|
||||
subject_id=fork.id,
|
||||
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
|
||||
)
|
||||
return ChatResponse.model_validate(fork, from_attributes=True)
|
||||
|
||||
|
||||
@router.post('/{id}/clone', response_model=ChatResponse | None)
|
||||
async def clone_chat_by_id(
|
||||
request: Request,
|
||||
|
|
@ -1545,6 +1781,7 @@ async def clone_chat_by_id(
|
|||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
|
|
@ -1562,7 +1799,7 @@ async def clone_chat_by_id(
|
|||
subject_id=chat.id,
|
||||
data={'original_chat_id': id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -1601,7 +1838,7 @@ async def clone_shared_chat_by_id(
|
|||
# Enforce access grants (owner and admins bypass)
|
||||
shared = await SharedChats.get_by_id(id, db=db)
|
||||
if shared and user.role != 'admin' and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
|
|
@ -1628,6 +1865,7 @@ async def clone_shared_chat_by_id(
|
|||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
|
|
@ -1638,7 +1876,7 @@ async def clone_shared_chat_by_id(
|
|||
|
||||
if chats:
|
||||
chat = chats[0]
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -1679,7 +1917,7 @@ async def archive_chat_by_id(
|
|||
subject_id=id,
|
||||
subject_type='chat',
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
|
@ -1713,7 +1951,7 @@ async def share_chat_by_id(
|
|||
subject_id=id,
|
||||
data={'share_id': chat.share_id, 'updated': True},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
# Create a new share
|
||||
shared = await SharedChats.create(id, user.id, db=db)
|
||||
|
|
@ -1731,7 +1969,7 @@ async def share_chat_by_id(
|
|||
subject_id=id,
|
||||
data={'share_id': shared.id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
# --- Delete Shared Chat ---
|
||||
|
|
@ -1795,11 +2033,13 @@ async def update_shared_chat_access_by_id(
|
|||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_chats',
|
||||
'sharing.open_chats',
|
||||
db=db,
|
||||
)
|
||||
|
||||
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1844,6 +2084,28 @@ class ChatFolderIdForm(BaseModel):
|
|||
folder_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/unread')
|
||||
async def mark_chat_unread_by_id(
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
folder_id = await Chats.get_chat_folder_id(id, user.id, db=db)
|
||||
return {
|
||||
'chat_id': id,
|
||||
'last_read_at': chat.last_read_at,
|
||||
'folder_id': folder_id,
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{id}/folder', response_model=ChatResponse | None)
|
||||
async def update_chat_folder_id_by_id(
|
||||
request: Request,
|
||||
|
|
@ -1874,7 +2136,7 @@ async def update_chat_folder_id_by_id(
|
|||
subject_id=id,
|
||||
data={'folder_id': form_data.folder_id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = {
|
|||
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
|
||||
'DEFAULT_MODEL_PARAMS': 'models.default_params',
|
||||
}
|
||||
SUBAGENTS_CONFIG_KEYS = {
|
||||
'ENABLE_SUBAGENTS': 'subagents.enable',
|
||||
'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled',
|
||||
'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent',
|
||||
'SUBAGENTS_MAX_ASYNC': 'subagents.max_async',
|
||||
'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations',
|
||||
'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output',
|
||||
'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
|
|
@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel):
|
|||
|
||||
config: dict | None = None
|
||||
|
||||
# Orchestrator policy fields
|
||||
server_type: str | None = None # "orchestrator", "terminal"
|
||||
server_type: str | None = None
|
||||
policy_id: str | None = None
|
||||
policy: dict | None = None # cached policy data
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
|
@ -321,7 +328,9 @@ async def set_terminal_servers_config(
|
|||
form_data: TerminalServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
|
||||
connections = [
|
||||
connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS
|
||||
]
|
||||
await Config.upsert({'terminal_server.connections': connections})
|
||||
|
||||
await set_terminal_servers(request)
|
||||
|
|
@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel):
|
|||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
policy_data: dict
|
||||
policy_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerLifecycleForm(BaseModel):
|
||||
|
|
@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel):
|
|||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
lifecycle_data: dict
|
||||
lifecycle_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerRefreshForm(BaseModel):
|
||||
|
|
@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel):
|
|||
async def put_terminal_server_policy(
|
||||
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a policy read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -433,8 +440,12 @@ async def put_terminal_server_policy(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
|
||||
async with session.put(
|
||||
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
async with session.request(
|
||||
'GET' if form_data.policy_data is None else 'PUT',
|
||||
policy_url,
|
||||
headers=headers,
|
||||
json=form_data.policy_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
return await resp.json()
|
||||
|
|
@ -443,17 +454,15 @@ async def put_terminal_server_policy(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save policy to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
|
||||
log.debug(f'Failed to access policy on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/lifecycle')
|
||||
async def put_terminal_server_lifecycle(
|
||||
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy lifecycle PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
|
||||
async with session.put(
|
||||
async with session.request(
|
||||
'GET' if form_data.lifecycle_data is None else 'PUT',
|
||||
lifecycle_url,
|
||||
headers=headers,
|
||||
json=form_data.lifecycle_data,
|
||||
|
|
@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save lifecycle to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
|
||||
log.debug(f'Failed to access lifecycle on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/refresh')
|
||||
|
|
@ -604,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
custom_headers = await get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
|
|
@ -649,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
custom_headers = await get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
|
|
@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user=
|
|||
return values
|
||||
|
||||
|
||||
class SubagentsConfigForm(BaseModel):
|
||||
ENABLE_SUBAGENTS: bool
|
||||
SUBAGENTS_BACKGROUND_ENABLED: bool
|
||||
SUBAGENTS_MAX_CONCURRENT: int
|
||||
SUBAGENTS_MAX_ASYNC: int
|
||||
SUBAGENTS_MAX_ITERATIONS: int
|
||||
SUBAGENTS_MAX_OUTPUT: int
|
||||
SUBAGENTS_SYSTEM_PROMPT: str
|
||||
|
||||
|
||||
@router.get('/subagents', response_model=SubagentsConfigForm)
|
||||
async def get_subagents_config(user=Depends(get_admin_user)):
|
||||
return await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/subagents', response_model=SubagentsConfigForm)
|
||||
async def set_subagents_config(
|
||||
request: Request,
|
||||
form_data: SubagentsConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS))
|
||||
values = await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CONFIG_UPDATED,
|
||||
actor=user,
|
||||
subject_id='subagents',
|
||||
subject_type='config',
|
||||
data={'enabled': values.get('ENABLE_SUBAGENTS')},
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class PromptSuggestion(BaseModel):
|
||||
title: list[str]
|
||||
content: str
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ async def create_feedback(
|
|||
EVENTS.FEEDBACK_CREATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ async def update_feedback_by_id(
|
|||
EVENTS.FEEDBACK_UPDATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
|
|
|||
|
|
@ -201,21 +201,28 @@ async def process_uploaded_file(
|
|||
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'),
|
||||
)
|
||||
# Keep the generic file status stream open until the
|
||||
# KB-specific vector write and durable link both finish.
|
||||
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
knowledge_file = 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'),
|
||||
db=db_session,
|
||||
)
|
||||
if not knowledge_file:
|
||||
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
|
||||
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}')
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error processing file: {file_item.id}')
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import uuid
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import (
|
||||
|
|
@ -22,6 +23,7 @@ from open_webui.models.folders import (
|
|||
FolderUpdateForm,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.automations import Automations
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
|
@ -30,6 +32,7 @@ from open_webui.utils.access_control import (
|
|||
)
|
||||
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 open_webui.tasks import has_active_tasks
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -42,6 +45,24 @@ router = APIRouter()
|
|||
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
|
||||
folders = await Folders.get_folders_by_user_id(user_id, db=db)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in folders}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
|
||||
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
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')
|
||||
|
|
@ -76,11 +97,12 @@ async def get_folders(
|
|||
await check_folders_permission(request, user, db=db)
|
||||
|
||||
folders = await Folders.get_folders_by_user_id(user.id, db=db)
|
||||
folder_ids = {folder.id for folder in folders}
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
for folder in folders:
|
||||
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
|
||||
if folder.parent_id and folder.parent_id not in folder_ids:
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
|
||||
|
||||
if folder.data and 'files' in folder.data:
|
||||
|
|
@ -91,9 +113,14 @@ async def get_folders(
|
|||
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
|
||||
)
|
||||
|
||||
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
|
||||
folder_list.append(folder)
|
||||
|
||||
return folder_list
|
||||
unread_counts = await get_folder_unread_counts(user.id, db=db)
|
||||
|
||||
return [
|
||||
FolderNameIdResponse(**folder.model_dump(), unread_count=unread_counts.get(folder.id, 0))
|
||||
for folder in folder_list
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -390,6 +417,11 @@ async def update_folder_is_expanded_by_id(
|
|||
):
|
||||
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:
|
||||
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)):
|
||||
return folder
|
||||
|
||||
if folder:
|
||||
try:
|
||||
folder = await Folders.update_folder_is_expanded_by_id_and_user_id(
|
||||
|
|
@ -477,6 +509,9 @@ async def update_folder_access_by_id(
|
|||
async def get_shared_folder_chats(
|
||||
request: Request,
|
||||
id: str,
|
||||
page: int | None = Query(None, ge=1),
|
||||
sort_by: str = Query('unread_updated_at'),
|
||||
sort_dir: str = Query('desc'),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -500,7 +535,18 @@ async def get_shared_folder_chats(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
|
||||
limit = 10
|
||||
skip = (page - 1) * limit if page is not None else 0
|
||||
chats = await Chats.get_all_chats_by_folder_id(
|
||||
id,
|
||||
skip=skip,
|
||||
limit=limit if page is not None else 60,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
unread_for_user_id=user.id,
|
||||
db=db,
|
||||
)
|
||||
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
|
||||
|
||||
# Resolve owner names for display (avatar URLs are constructed client-side)
|
||||
owner_cache: dict[str, str] = {}
|
||||
|
|
@ -510,11 +556,57 @@ async def get_shared_folder_chats(
|
|||
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]
|
||||
chat['active'] = False
|
||||
if chat['user_id'] != user.id:
|
||||
chat['last_read_at'] = chat['updated_at']
|
||||
if await has_active_tasks(request.app.state.redis, chat['id']):
|
||||
chat['active'] = await ChatMessages.has_unfinished_assistant_by_chat_id(chat['id'], db=db)
|
||||
|
||||
return {
|
||||
response = {
|
||||
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
|
||||
'folder_permission': 'write' if has_write else 'read',
|
||||
}
|
||||
if page is not None:
|
||||
response.update({'total': total, 'has_more': skip + limit < total})
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/{id}/read')
|
||||
async def mark_folder_chats_read_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(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'
|
||||
if not (is_owner or is_admin or await _has_folder_access(user.id, folder, 'read', db)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folder_ids = (
|
||||
await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder.user_id, db=db)
|
||||
if is_owner or is_admin
|
||||
else [id]
|
||||
)
|
||||
updated_count = await Chats.mark_chats_read_by_folder_ids(user.id, folder_ids, db=db)
|
||||
|
||||
return {
|
||||
'folder_id': id,
|
||||
'folder_ids': folder_ids,
|
||||
'updated_count': updated_count,
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -585,6 +677,8 @@ async def delete_folder_by_id(
|
|||
# Clean up access grants for this folder
|
||||
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
|
||||
|
||||
await Automations.clear_folder_ids(folder_owner_id, folder_ids, db=db)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.FOLDER_DELETED,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.functions import (
|
||||
FunctionForm,
|
||||
|
|
@ -46,11 +46,17 @@ router = APIRouter()
|
|||
|
||||
@router.get('/', response_model=list[FunctionResponse])
|
||||
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(db=db)
|
||||
|
||||
|
||||
@router.get('/list', response_model=list[FunctionUserResponse])
|
||||
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_function_list(db=db)
|
||||
|
||||
|
||||
|
|
@ -65,6 +71,9 @@ async def get_functions(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(include_valves=include_valves, db=db)
|
||||
|
||||
|
||||
|
|
@ -284,6 +293,22 @@ async def toggle_function_by_id(
|
|||
):
|
||||
function = await Functions.get_function_by_id(id, db=db)
|
||||
if function:
|
||||
lifecycle_event = build_event(
|
||||
request,
|
||||
EVENTS.FUNCTION_DISABLE_STARTED if function.is_active else EVENTS.FUNCTION_ENABLE_STARTED,
|
||||
actor=user,
|
||||
subject_id=function.id,
|
||||
subject_type='function',
|
||||
data={'type': function.type, 'name': function.name},
|
||||
)
|
||||
await dispatch_event_functions(
|
||||
request.app,
|
||||
lifecycle_event,
|
||||
request=request,
|
||||
extra_function_ids=[function.id] if not function.is_active else None,
|
||||
)
|
||||
schedule_webhook_dispatch(request.app, lifecycle_event)
|
||||
|
||||
function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db)
|
||||
|
||||
if function:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from types import SimpleNamespace
|
|||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
|
@ -785,7 +786,11 @@ async def image_generations(
|
|||
images.append({'url': url})
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
if form_data.model:
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
# persists the global default and switches the shared backend. Only an
|
||||
# admin may do that; a non-admin generates on the currently configured
|
||||
# checkpoint. The model field is not a per-user selection on this backend.
|
||||
if form_data.model and user.role == 'admin':
|
||||
await set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
|
|
@ -936,10 +941,10 @@ async def image_edits(
|
|||
if isinstance(file_response, FileResponse):
|
||||
file_path = file_response.path
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
|
||||
return f'data:{mime_type};base64,{image_data}'
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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 (
|
||||
KNOWLEDGE_SORTABLE_FIELDS,
|
||||
KnowledgeDirectoryForm,
|
||||
KnowledgeDirectoryModel,
|
||||
KnowledgeFileListResponse,
|
||||
|
|
@ -181,6 +182,8 @@ async def search_knowledge_bases(
|
|||
view_option: str | None = None,
|
||||
source: str | None = None,
|
||||
page: int | None = 1,
|
||||
order_by: str | None = None,
|
||||
direction: str | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -195,6 +198,10 @@ async def search_knowledge_bases(
|
|||
filter['view_option'] = view_option
|
||||
if source in {'local', 'external'}:
|
||||
filter['source'] = source
|
||||
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
|
||||
filter['order_by'] = order_by
|
||||
if direction in {'asc', 'desc'}:
|
||||
filter['direction'] = direction
|
||||
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from open_webui.models.models import (
|
|||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def add_chat_variables_schema(model_dict: dict) -> dict:
|
||||
system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None
|
||||
schema = get_chat_variables_schema(system)
|
||||
if schema:
|
||||
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
return model_dict
|
||||
|
||||
|
||||
def _safe_static_redirect_path(url: str) -> str | None:
|
||||
"""
|
||||
If url is a same-origin static asset path, return a normalized path safe for
|
||||
|
|
@ -119,7 +128,11 @@ async def _verify_knowledge_file_access(
|
|||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py
|
||||
@router.get(
|
||||
'/list',
|
||||
response_model=ModelAccessListResponse,
|
||||
response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
|
||||
) # do NOT use "/" as path, conflicts with main.py
|
||||
async def get_models(
|
||||
query: str | None = None,
|
||||
view_option: str | None = None,
|
||||
|
|
@ -173,7 +186,7 @@ async def get_models(
|
|||
# Strip profile_image_url from meta — images are served via /model/profile/image.
|
||||
items = []
|
||||
for model in result.items:
|
||||
data = model.model_dump()
|
||||
data = add_chat_variables_schema(model.model_dump())
|
||||
if data.get('meta'):
|
||||
data['meta'].pop('profile_image_url', None)
|
||||
items.append(
|
||||
|
|
@ -415,7 +428,10 @@ async def import_models(
|
|||
continue
|
||||
|
||||
# Update existing model
|
||||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['meta'] = {
|
||||
**existing_model.meta.model_dump(),
|
||||
**(model_data.get('meta') or {}),
|
||||
}
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
|
||||
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
|
||||
|
|
@ -520,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
|||
db=db,
|
||||
):
|
||||
model_dict = model.model_dump()
|
||||
model_dict = add_chat_variables_schema(model_dict)
|
||||
# Strip params (system prompt and other admin-curated config)
|
||||
# for read-only callers — matches the params strip already
|
||||
# enforced on /api/models in utils/models.py. Owners, admins
|
||||
|
|
@ -721,6 +738,9 @@ async def update_model_by_id(
|
|||
if 'base_model_id' not in form_data.model_fields_set:
|
||||
form_data.base_model_id = model.base_model_id
|
||||
|
||||
if 'profile_image_url' not in form_data.meta.model_fields_set:
|
||||
form_data.meta.profile_image_url = model.meta.profile_image_url
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from open_webui.config import (
|
||||
|
|
@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import ChatForm, ChatResponse, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.notes import (
|
||||
|
|
@ -303,6 +305,229 @@ async def get_note_by_id(
|
|||
)
|
||||
|
||||
|
||||
@router.get('/{id}/chat', response_model=ChatResponse)
|
||||
async def get_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
|
||||
if chat:
|
||||
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
|
||||
if updated_chat:
|
||||
return updated_chat
|
||||
|
||||
return chat
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
@router.get('/{id}/chats', response_model=list[ChatResponse])
|
||||
async def get_note_chats_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
|
||||
normalized_chats = []
|
||||
for chat in chats:
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat
|
||||
|
||||
normalized_chats.append(chat)
|
||||
|
||||
return normalized_chats
|
||||
|
||||
|
||||
@router.post('/{id}/chat', response_model=ChatResponse)
|
||||
async def create_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteById
|
||||
############################
|
||||
|
|
@ -354,9 +579,15 @@ async def update_note_by_id(
|
|||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
|
||||
event_data = note.model_dump()
|
||||
if form_data.data is not None:
|
||||
event_data['data'] = {
|
||||
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
|
||||
}
|
||||
|
||||
await sio.emit(
|
||||
'note-events',
|
||||
note.model_dump(),
|
||||
'events:note',
|
||||
event_data,
|
||||
to=f'note:{note.id}',
|
||||
)
|
||||
|
||||
|
|
|
|||
100
backend/open_webui/routers/notifications.py
Normal file
100
backend/open_webui/routers/notifications.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.notifications import (
|
||||
create_target,
|
||||
delete_target,
|
||||
get_notification_event_catalog,
|
||||
list_targets,
|
||||
set_default_target,
|
||||
test_target,
|
||||
update_target,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NotificationTargetForm(BaseModel):
|
||||
id: str | None = None
|
||||
type: str | None = None
|
||||
enabled: bool | None = None
|
||||
events: list[str] | None = None
|
||||
delivery: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _check_notifications_access(user) -> None:
|
||||
if not await Config.get('ui.enable_user_webhooks'):
|
||||
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.webhooks', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
async def get_notification_events(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return {'events': get_notification_event_catalog()}
|
||||
|
||||
|
||||
@router.get('/targets')
|
||||
async def get_notification_targets(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return await list_targets(user.id)
|
||||
|
||||
|
||||
@router.post('/targets')
|
||||
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await create_target(user.id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}')
|
||||
async def update_notification_target(
|
||||
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
|
||||
):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete('/targets/{target_id}')
|
||||
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
if not await delete_target(user.id, target_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}/default')
|
||||
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await set_default_target(user.id, target_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/targets/{target_id}/test')
|
||||
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
return await test_target(user.id, target_id, app_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
|
@ -11,6 +11,7 @@ from datetime import datetime
|
|||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
|
|
@ -25,6 +26,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
|
|
@ -39,13 +41,14 @@ from open_webui.models.users import UserModel
|
|||
from open_webui.utils.access_control import check_model_access
|
||||
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
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.misc import calculate_sha256
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
apply_model_params_to_body_openai,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
|
||||
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -97,6 +100,8 @@ async def send_request(
|
|||
key: str | None = None,
|
||||
user: UserModel = None,
|
||||
stream: bool = False,
|
||||
# passthrough must stay False for /api/chat: middleware parses it per line
|
||||
passthrough: bool = False,
|
||||
content_type: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
api_config: dict | None = None,
|
||||
|
|
@ -119,7 +124,7 @@ async def send_request(
|
|||
|
||||
# Custom per-connection headers last so admin-set headers take precedence.
|
||||
if api_config and api_config.get('headers'):
|
||||
headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request))
|
||||
headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request))
|
||||
|
||||
r = await session.request(
|
||||
method,
|
||||
|
|
@ -127,7 +132,7 @@ async def send_request(
|
|||
data=payload,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=stream),
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
|
|
@ -168,7 +173,7 @@ async def send_request(
|
|||
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=passthrough),
|
||||
status_code=r.status,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
|
@ -313,6 +318,16 @@ async def update_config(
|
|||
'ollama.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OLLAMA_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
|
|
@ -355,6 +370,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
|
|||
return api_configs.get(str(idx), api_configs.get(url, {}))
|
||||
|
||||
|
||||
async def get_ollama_connection_config() -> tuple[list, dict]:
|
||||
"""Base URLs and per-connection API configs in one batched SELECT."""
|
||||
config = await Config.get_many('ollama.base_urls', 'ollama.api_configs')
|
||||
return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {})
|
||||
|
||||
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
|
||||
|
|
@ -517,6 +538,7 @@ async def get_ollama_loaded_models(
|
|||
@router.get('/api/version/{url_idx}')
|
||||
async def get_ollama_versions(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
url_idx: int | None = None,
|
||||
):
|
||||
"""Return the lowest Ollama version across all configured backends."""
|
||||
|
|
@ -638,6 +660,7 @@ async def pull_model(
|
|||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -677,6 +700,7 @@ async def push_model(
|
|||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -709,6 +733,7 @@ async def create_model(
|
|||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -871,16 +896,13 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/embed',
|
||||
|
|
@ -925,16 +947,13 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/embeddings',
|
||||
|
|
@ -984,22 +1003,20 @@ 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 = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
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, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1051,6 +1068,10 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
|
|||
await validate_ollama_backend_idx(request, model, url_idx, user)
|
||||
if url_idx is None:
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models.cache.clear()
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if model not in models:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -1119,16 +1140,16 @@ 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((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/chat',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=form_data.stream,
|
||||
content_type='application/x-ndjson',
|
||||
|
|
@ -1170,6 +1191,14 @@ class OpenAICompletionForm(BaseModel):
|
|||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
class OpenAIEmbeddingsForm(BaseModel):
|
||||
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
|
||||
|
||||
model: str
|
||||
input: object
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
@router.post('/v1/completions')
|
||||
@router.post('/v1/completions/{url_idx}')
|
||||
async def generate_openai_completion(
|
||||
|
|
@ -1207,18 +1236,68 @@ 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((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/v1/embeddings')
|
||||
@router.post('/v1/embeddings/{url_idx}')
|
||||
async def generate_openai_embeddings(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
url_idx: int | None = None,
|
||||
user=Depends(get_verified_user), # noqa: B008
|
||||
):
|
||||
"""Forward an embeddings request via the OpenAI-compatible proxy."""
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
metadata = form_data.pop('metadata', None)
|
||||
|
||||
try:
|
||||
form_data = OpenAIEmbeddingsForm(**form_data)
|
||||
except Exception as exc:
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
payload.pop('metadata', None)
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info is not None:
|
||||
if model_info.base_model_id:
|
||||
payload['model'] = model_info.base_model_id
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/embeddings',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
|
|
@ -1266,18 +1345,19 @@ 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((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/chat/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
|
|
@ -1317,21 +1397,19 @@ 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 = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/messages',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
|
|
@ -1377,21 +1455,19 @@ 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 = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/responses',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
|
|
@ -1462,7 +1538,7 @@ async def download_file_stream(
|
|||
file_url: str,
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
):
|
||||
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
|
||||
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
|
|
@ -1477,37 +1553,38 @@ async def download_file_stream(
|
|||
) as response:
|
||||
total_size = int(response.headers.get('content-length', 0)) + current_size
|
||||
|
||||
with open(file_path, 'ab+') as f:
|
||||
async with aiofiles.open(file_path, 'ab') as f:
|
||||
async for data in response.content.iter_chunked(chunk_size):
|
||||
current_size += len(data)
|
||||
f.write(data)
|
||||
await f.write(data)
|
||||
|
||||
done = current_size == total_size
|
||||
progress = round((current_size / total_size) * 100, 2)
|
||||
progress_total = total_size or current_size
|
||||
progress = round((current_size / progress_total) * 100, 2)
|
||||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
f.close()
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
done = True
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as blob_f:
|
||||
return blob_f.read()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
blob_size = await asyncio.to_thread(os.path.getsize, file_path)
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
os.remove(file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(blob_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
|
||||
@router.post('/models/download')
|
||||
|
|
@ -1554,17 +1631,11 @@ async def upload_model(
|
|||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# Stage 1: persist the uploaded file to disk
|
||||
chunk_size = 1024 * 1024 * 2 # 2 MiB
|
||||
chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
|
||||
def _persist_upload():
|
||||
with open(file_path, 'wb') as out_f:
|
||||
while True:
|
||||
chunk = file.file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
out_f.write(chunk)
|
||||
|
||||
await asyncio.to_thread(_persist_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as out_f:
|
||||
while chunk := await file.read(chunk_size):
|
||||
await out_f.write(chunk)
|
||||
|
||||
async def file_process_stream():
|
||||
nonlocal ollama_url
|
||||
|
|
@ -1576,25 +1647,26 @@ async def upload_model(
|
|||
log.info(f'Model Hash: {file_hash}')
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
bytes_read = 0
|
||||
while chunk := f.read(chunk_size):
|
||||
bytes_read = 0
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
while chunk := await f.read(chunk_size):
|
||||
bytes_read += len(chunk)
|
||||
progress = round(bytes_read / total_size * 100, 2)
|
||||
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
|
||||
|
||||
# Stage 3: push blob to Ollama
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read})
|
||||
yield f'data: {event}\n\n'
|
||||
|
||||
session = await get_session()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
|
||||
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(total_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as resp:
|
||||
|
|
@ -1602,7 +1674,7 @@ async def upload_model(
|
|||
raise Exception('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
log.info('Uploaded to /api/blobs')
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
# Stage 4: create the model
|
||||
model, _ext = os.path.splitext(filename)
|
||||
|
|
@ -1623,7 +1695,10 @@ async def upload_model(
|
|||
) as create_resp:
|
||||
if create_resp.ok:
|
||||
log.info('API SUCCESS!')
|
||||
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
|
||||
event = json.dumps(
|
||||
{'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model}
|
||||
)
|
||||
yield f'data: {event}\n\n'
|
||||
else:
|
||||
resp_text = await create_resp.text()
|
||||
raise Exception(f'Failed to create model in Ollama. {resp_text}')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import re
|
|||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
|
|
@ -43,6 +44,7 @@ from open_webui.utils.access_control import check_model_access, has_connection_a
|
|||
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
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.misc import (
|
||||
convert_logit_bias_input_to_json,
|
||||
stream_chunks_handler,
|
||||
|
|
@ -53,6 +55,7 @@ from open_webui.utils.payload import (
|
|||
)
|
||||
from open_webui.utils.session_pool import (
|
||||
cleanup_response,
|
||||
get_client_timeout,
|
||||
get_session,
|
||||
stream_wrapper,
|
||||
)
|
||||
|
|
@ -209,7 +212,7 @@ async def get_headers_and_cookies(
|
|||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
if config.get('headers') and isinstance(config.get('headers'), dict):
|
||||
custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request)
|
||||
custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request)
|
||||
headers.update(custom_headers)
|
||||
|
||||
return headers, cookies
|
||||
|
|
@ -301,6 +304,90 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
|
|||
return url, key, api_config
|
||||
|
||||
|
||||
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
|
||||
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
|
||||
requested_model = form_data.get('model')
|
||||
if not requested_model:
|
||||
raise HTTPException(status_code=400, detail='model is required')
|
||||
|
||||
payload = {**form_data}
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL)
|
||||
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
payload['model'] = model_id
|
||||
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
|
||||
model = models.get(model_id)
|
||||
if not model or 'urlIdx' not in model:
|
||||
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND())
|
||||
|
||||
url, key, api_config = await get_openai_connection(model['urlIdx'])
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
return requested_model, payload, url, key, headers, cookies
|
||||
|
||||
|
||||
async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int:
|
||||
"""Forward an Anthropic token-count request through an OpenAI-compatible connection."""
|
||||
requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages/count_tokens'
|
||||
response = None
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
await publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
provider='openai-compatible',
|
||||
base_url=url,
|
||||
api_key=key,
|
||||
status=response.status,
|
||||
requested_model=requested_model,
|
||||
upstream_error=response_data,
|
||||
)
|
||||
raise HTTPException(status_code=response.status, detail=response_data)
|
||||
|
||||
input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None
|
||||
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
|
||||
raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider')
|
||||
|
||||
return input_tokens
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception('Failed to count Anthropic tokens for model %s', requested_model)
|
||||
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
finally:
|
||||
await cleanup_response(response)
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return await get_openai_config()
|
||||
|
|
@ -333,6 +420,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
|
|||
'openai.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
|
|
@ -396,13 +493,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
|
||||
r.raise_for_status()
|
||||
|
||||
# Save the streaming content to a file
|
||||
with open(file_path, 'wb') as f:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
async for chunk in r.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
|
||||
with open(file_body_path, 'w') as f:
|
||||
json.dump(json.loads(body.decode('utf-8')), f)
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(json.loads(body.decode('utf-8'))))
|
||||
|
||||
# Return the saved file
|
||||
return FileResponse(file_path)
|
||||
|
|
@ -551,6 +647,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
|||
|
||||
enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config()
|
||||
if not enable_openai_api:
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
return {'data': []}
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
|
|
@ -1107,6 +1204,9 @@ async def generate_chat_completion(
|
|||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not await Config.get('openai.enable'):
|
||||
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
|
|
@ -1169,8 +1269,7 @@ async def generate_chat_completion(
|
|||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
# Add user info to the payload if the model is a pipeline
|
||||
if 'pipeline' in model and model.get('pipeline'):
|
||||
|
|
@ -1246,6 +1345,7 @@ async def generate_chat_completion(
|
|||
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
|
||||
)
|
||||
|
||||
is_streaming_request = bool(payload.get('stream', False))
|
||||
payload = json.dumps(payload)
|
||||
|
||||
r = None
|
||||
|
|
@ -1262,7 +1362,7 @@ async def generate_chat_completion(
|
|||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
|
|
@ -1420,7 +1520,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
|
|
@ -1489,6 +1589,7 @@ async def responses(
|
|||
Routes to the correct upstream backend based on the model field.
|
||||
"""
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
is_streaming_request = bool(payload.get('stream', False))
|
||||
|
||||
idx = 0
|
||||
model_id = form_data.model
|
||||
|
|
@ -1539,14 +1640,14 @@ async def responses(
|
|||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
|
|
@ -1609,6 +1710,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
payload = None
|
||||
is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False
|
||||
|
||||
idx = 0
|
||||
model_id = payload.get('model') if isinstance(payload, dict) else None
|
||||
|
|
@ -1660,14 +1762,14 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -18,7 +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.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.routers.openai import get_all_models_responses
|
||||
|
|
@ -237,35 +237,37 @@ async def upload_pipeline(
|
|||
|
||||
response = None
|
||||
try:
|
||||
# Save the uploaded file off the event loop (uploads can be large).
|
||||
def _save_upload():
|
||||
with open(file_path, 'wb') as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
await asyncio.to_thread(_save_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as buffer:
|
||||
while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
await buffer.write(chunk)
|
||||
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
headers = {'Authorization': f'Bearer {key}'}
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
with open(file_path, 'rb') as f:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field(
|
||||
'file',
|
||||
f,
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
form_data = aiohttp.FormData()
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
async def pipeline_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as pipeline_file:
|
||||
while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field(
|
||||
'file',
|
||||
pipeline_chunks(),
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -297,7 +299,7 @@ async def upload_pipeline(
|
|||
finally:
|
||||
# Ensure the file is deleted after the upload is completed or on failure
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
|
||||
class AddPipelineForm(BaseModel):
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud
|
|||
from open_webui.retrieval.web.perplexity import search_perplexity
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.searchapi import search_searchapi
|
||||
from open_webui.retrieval.web.openserp import search_openserp
|
||||
from open_webui.retrieval.web.searxng import search_searxng
|
||||
from open_webui.retrieval.web.serpapi import search_serpapi
|
||||
from open_webui.retrieval.web.serper import search_serper
|
||||
|
|
@ -127,6 +128,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TIKTOKEN_DISALLOWED_SPECIAL = ()
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
|
|
@ -363,6 +366,7 @@ RETRIEVAL_CONFIG_KEYS = {
|
|||
'SEARCHAPI_ENGINE': 'web.search.searchapi_engine',
|
||||
'SEARXNG_LANGUAGE': 'web.search.searxng_language',
|
||||
'SEARXNG_QUERY_URL': 'web.search.searxng_query_url',
|
||||
'OPENSERP_BASE_URL': 'web.search.openserp_base_url',
|
||||
'SERPAPI_API_KEY': 'web.search.serpapi_api_key',
|
||||
'SERPAPI_ENGINE': 'web.search.serpapi_engine',
|
||||
'SERPER_API_KEY': 'web.search.serper_api_key',
|
||||
|
|
@ -701,6 +705,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
|
||||
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
|
||||
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
|
||||
'YACY_QUERY_URL': config.YACY_QUERY_URL,
|
||||
'YACY_USERNAME': config.YACY_USERNAME,
|
||||
'YACY_PASSWORD': config.YACY_PASSWORD,
|
||||
|
|
@ -779,6 +784,7 @@ class WebConfig(BaseModel):
|
|||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: str | None = None
|
||||
SEARXNG_QUERY_URL: str | None = None
|
||||
SEARXNG_LANGUAGE: str | None = None
|
||||
OPENSERP_BASE_URL: str | None = None
|
||||
YACY_QUERY_URL: str | None = None
|
||||
YACY_USERNAME: str | None = None
|
||||
YACY_PASSWORD: str | None = None
|
||||
|
|
@ -1247,6 +1253,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE
|
||||
config.OPENSERP_BASE_URL = form_data.web.OPENSERP_BASE_URL
|
||||
config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
|
||||
|
|
@ -1398,6 +1405,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
|
||||
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
|
||||
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
|
||||
'YACY_QUERY_URL': config.YACY_QUERY_URL,
|
||||
'YACY_USERNAME': config.YACY_USERNAME,
|
||||
'YACY_PASSWORD': config.YACY_PASSWORD,
|
||||
|
|
@ -1582,7 +1590,7 @@ def get_splitter_length_function(
|
|||
) -> Callable[[str], int]:
|
||||
if config.TEXT_SPLITTER == 'token':
|
||||
encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=()))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL))
|
||||
|
||||
if config.TEXT_SPLITTER == 'token_transformers':
|
||||
tokenizer = get_transformers_tokenizer(request, config)
|
||||
|
|
@ -1689,6 +1697,7 @@ def save_docs_to_vector_db(
|
|||
chunk_size=config.CHUNK_SIZE,
|
||||
chunk_overlap=config.CHUNK_OVERLAP,
|
||||
add_start_index=True,
|
||||
disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL,
|
||||
)
|
||||
docs = text_splitter.split_documents(docs)
|
||||
elif config.TEXT_SPLITTER == 'token_transformers':
|
||||
|
|
@ -2209,6 +2218,16 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
|
|||
)
|
||||
else:
|
||||
raise Exception('No SEARXNG_QUERY_URL found in environment variables')
|
||||
elif engine == 'openserp':
|
||||
if config.OPENSERP_BASE_URL:
|
||||
return await search_openserp(
|
||||
config.OPENSERP_BASE_URL,
|
||||
query,
|
||||
config.WEB_SEARCH_RESULT_COUNT,
|
||||
config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception('No OPENSERP_BASE_URL found in environment variables')
|
||||
elif engine == 'yacy':
|
||||
if config.YACY_QUERY_URL:
|
||||
return await asyncio.to_thread(
|
||||
|
|
@ -2609,6 +2628,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
|||
verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
trust_env=config.WEB_SEARCH_TRUST_ENV,
|
||||
loader_config=await get_loader_config(),
|
||||
)
|
||||
docs = await loader.aload()
|
||||
|
||||
|
|
@ -2910,21 +2930,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
|
|||
folder = f'{UPLOAD_DIR}'
|
||||
try:
|
||||
# Check if the directory exists
|
||||
if os.path.exists(folder):
|
||||
if await asyncio.to_thread(os.path.exists, folder):
|
||||
# Iterate over all the files and directories in the specified directory
|
||||
for filename in os.listdir(folder):
|
||||
for filename in await asyncio.to_thread(os.listdir, folder):
|
||||
file_path = os.path.join(folder, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path) or os.path.islink(file_path):
|
||||
os.unlink(file_path) # Remove the file or link
|
||||
elif os.path.isdir(file_path):
|
||||
shutil.rmtree(file_path) # Remove the directory
|
||||
if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread(
|
||||
os.path.islink, file_path
|
||||
):
|
||||
await asyncio.to_thread(os.unlink, file_path) # Remove the file or link
|
||||
elif await asyncio.to_thread(os.path.isdir, file_path):
|
||||
await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to delete {file_path}. Reason: {e}')
|
||||
else:
|
||||
log.warning(f'The directory {folder} does not exist')
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to process the directory {folder}. Reason: {e}')
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.RETRIEVAL_UPLOADS_RESET,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ async def get_skills(
|
|||
async def get_skill_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -86,6 +88,10 @@ async def get_skill_list(
|
|||
filter['query'] = query
|
||||
if view_option:
|
||||
filter['view_option'] = view_option
|
||||
if order_by:
|
||||
filter['order_by'] = order_by
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
|
|
|
|||
|
|
@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
|||
##################################
|
||||
|
||||
|
||||
class ActiveChatsForm(BaseModel):
|
||||
chat_ids: list[str]
|
||||
|
||||
|
||||
@router.post('/active/chats')
|
||||
async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)):
|
||||
"""Check which chat IDs have active tasks."""
|
||||
from open_webui.tasks import get_active_chat_ids
|
||||
|
||||
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
|
||||
return {'active_chat_ids': active}
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
return await get_config_values(TASK_CONFIG_KEYS)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ from open_webui.events import EVENTS, publish_event
|
|||
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
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
|
|
@ -49,6 +49,10 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
|||
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
|
||||
if unquote(decoded) != decoded:
|
||||
return None
|
||||
# posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component.
|
||||
# Upstreams that treat '\' as a separator would resolve it, so reject outright.
|
||||
if '\\' in decoded:
|
||||
return None
|
||||
had_trailing_slash = decoded.endswith('/')
|
||||
normalized = posixpath.normpath(decoded)
|
||||
# Remove any leading slashes that would reset the base
|
||||
|
|
@ -100,7 +104,7 @@ async def proxy_terminal(
|
|||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
return JSONResponse({'error': 'Access denied'}, status_code=403)
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503)
|
||||
|
||||
|
|
@ -110,11 +114,6 @@ async def proxy_terminal(
|
|||
|
||||
target_url = f'{base_url}/{safe_path}'
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
if policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
|
||||
|
||||
if request.query_params:
|
||||
target_url += f'?{request.query_params}'
|
||||
|
||||
|
|
@ -212,7 +211,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from open_webui.utils.auth import decode_token, is_valid_token
|
||||
from open_webui.utils.auth import get_verified_user_by_token
|
||||
|
||||
# First-message authentication
|
||||
try:
|
||||
|
|
@ -221,14 +220,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
if payload.get('type') != 'auth':
|
||||
await ws.close(code=4001, reason='Expected auth message')
|
||||
return None
|
||||
token = payload.get('token', '')
|
||||
data = decode_token(token)
|
||||
if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)):
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
|
||||
if user is None:
|
||||
await ws.close(code=4001, reason='User not found')
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||
await ws.close(code=4001, reason='Auth timeout or invalid payload')
|
||||
|
|
@ -272,7 +266,7 @@ async def ws_terminal(
|
|||
return
|
||||
user, connection = result
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
await ws.close(code=4003, reason='Terminal server URL not configured')
|
||||
return
|
||||
|
|
@ -280,8 +274,6 @@ async def ws_terminal(
|
|||
# Build upstream WebSocket URL (no token in URL)
|
||||
ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://')
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
upstream_params = {}
|
||||
# For orchestrator-backed servers, pass user_id
|
||||
upstream_params['user_id'] = user.id
|
||||
|
|
@ -292,10 +284,7 @@ async def ws_terminal(
|
|||
# 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/{safe_session_id}'
|
||||
else:
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
if upstream_params:
|
||||
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
|
@ -72,20 +72,23 @@ async def get_tools(
|
|||
tools = []
|
||||
|
||||
# Local Tools
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
if ENABLE_PLUGINS:
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves')
|
||||
if tool_module
|
||||
else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
server_access_grants = {}
|
||||
|
|
@ -199,6 +202,9 @@ async def get_tools(
|
|||
|
||||
@router.get('/list', response_model=list[ToolAccessResponse])
|
||||
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
tools = await Tools.get_tools(defer_content=True, db=db)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import base64
|
|||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
|
|
@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL
|
|||
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.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import (
|
||||
|
|
@ -40,6 +44,7 @@ from open_webui.utils.auth import (
|
|||
get_verified_user,
|
||||
validate_password,
|
||||
)
|
||||
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -196,12 +201,14 @@ class SharingPermissions(BaseModel):
|
|||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
folders: bool = False
|
||||
public_chats: bool = False
|
||||
public_calendars: bool = False
|
||||
|
||||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
allow_users: bool = True
|
||||
allow_groups: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
|
|
@ -259,6 +266,129 @@ class UserPermissions(BaseModel):
|
|||
settings: SettingsPermissions
|
||||
|
||||
|
||||
class UserUsageTotals(BaseModel):
|
||||
lifetime_tokens: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
peak_daily_tokens: int = 0
|
||||
longest_chat_seconds: int = 0
|
||||
current_streak: int = 0
|
||||
longest_streak: int = 0
|
||||
total_chats: int = 0
|
||||
active_days: int = 0
|
||||
models_used: int = 0
|
||||
messages: int = 0
|
||||
user_messages: int = 0
|
||||
assistant_messages: int = 0
|
||||
|
||||
|
||||
class UserUsageHeatmapEntry(BaseModel):
|
||||
date: str
|
||||
messages: int = 0
|
||||
chats: int = 0
|
||||
tokens: int = 0
|
||||
models: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserUsageModelEntry(BaseModel):
|
||||
model_id: str
|
||||
messages: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class UserUsageToolEntry(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class UserUsageInsights(BaseModel):
|
||||
most_used_model: Optional[str] = None
|
||||
average_tokens_per_chat: float = 0
|
||||
average_messages_per_active_day: float = 0
|
||||
user_message_share: float = 0
|
||||
assistant_message_share: float = 0
|
||||
|
||||
|
||||
class UserUsagePeriod(BaseModel):
|
||||
start_date: int
|
||||
end_date: int
|
||||
days: int
|
||||
|
||||
|
||||
class UserUsageResponse(BaseModel):
|
||||
totals: UserUsageTotals
|
||||
heatmap: list[UserUsageHeatmapEntry]
|
||||
weekly_heatmap: list[UserUsageHeatmapEntry]
|
||||
cumulative_heatmap: list[UserUsageHeatmapEntry]
|
||||
insights: UserUsageInsights
|
||||
top_models: list[UserUsageModelEntry]
|
||||
top_tools: list[UserUsageToolEntry] = []
|
||||
period: UserUsagePeriod
|
||||
|
||||
|
||||
def _week_start(date: datetime) -> datetime:
|
||||
return date - timedelta(days=date.weekday())
|
||||
|
||||
|
||||
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
weeks: dict[str, dict] = {}
|
||||
for day in heatmap:
|
||||
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
|
||||
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
|
||||
entry['messages'] += day.get('messages', 0)
|
||||
entry['chats'] += day.get('chats', 0)
|
||||
entry['tokens'] += day.get('tokens', 0)
|
||||
entry['models'].update(day.get('models', {}))
|
||||
|
||||
return [
|
||||
{
|
||||
**weeks[key],
|
||||
'models': dict(weeks[key]['models']),
|
||||
}
|
||||
for key in sorted(weeks)
|
||||
]
|
||||
|
||||
|
||||
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
|
||||
models: Counter[str] = Counter()
|
||||
cumulative = []
|
||||
for day in heatmap:
|
||||
totals['messages'] += day.get('messages', 0)
|
||||
totals['chats'] += day.get('chats', 0)
|
||||
totals['tokens'] += day.get('tokens', 0)
|
||||
models.update(day.get('models', {}))
|
||||
cumulative.append(
|
||||
{
|
||||
'date': day['date'],
|
||||
**totals,
|
||||
'models': dict(models),
|
||||
}
|
||||
)
|
||||
return cumulative
|
||||
|
||||
|
||||
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
|
||||
longest = 0
|
||||
current_run = 0
|
||||
for day in heatmap:
|
||||
if day.get('messages', 0) > 0:
|
||||
current_run += 1
|
||||
longest = max(longest, current_run)
|
||||
else:
|
||||
current_run = 0
|
||||
|
||||
current = 0
|
||||
for day in reversed(heatmap):
|
||||
if day.get('messages', 0) <= 0:
|
||||
break
|
||||
current += 1
|
||||
|
||||
return {'current': current, 'longest': longest}
|
||||
|
||||
|
||||
@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')
|
||||
|
|
@ -348,6 +478,23 @@ async def update_user_settings_by_session_user(
|
|||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
updated_user_settings['ui'].pop('toolServers', None)
|
||||
|
||||
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and (
|
||||
'notifications' in updated_user_settings
|
||||
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
|
||||
)
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.webhooks',
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
):
|
||||
updated_user_settings.pop('notifications', None)
|
||||
if isinstance(ui_notifications, dict):
|
||||
ui_notifications.pop('webhook_url', None)
|
||||
|
||||
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
|
||||
if user:
|
||||
await publish_event(
|
||||
|
|
@ -428,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
|
|||
return user.info
|
||||
|
||||
|
||||
class UserVariablesForm(BaseModel):
|
||||
variables: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserVariablesResponse(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/user/variables', response_model=UserVariablesResponse)
|
||||
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
|
||||
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.post('/user/variables/update', response_model=UserVariablesResponse)
|
||||
async def update_user_variables_by_session_user(
|
||||
form_data: UserVariablesForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
variables = validate_user_variables(form_data.variables)
|
||||
except ChatVariablesError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
return UserVariablesResponse(variables=variables)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserInfoBySessionUser
|
||||
############################
|
||||
|
|
@ -455,6 +648,86 @@ async def update_user_info_by_session_user( # PATCH-style merge
|
|||
return updated.info
|
||||
|
||||
|
||||
############################
|
||||
# GetUserUsageBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/usage', response_model=UserUsageResponse)
|
||||
async def get_user_usage_by_session_user(
|
||||
days: Optional[int] = Query(None, ge=7, le=732),
|
||||
start_date: Optional[int] = Query(None),
|
||||
end_date: Optional[int] = Query(None),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
now = int(time.time())
|
||||
period_end = end_date or now
|
||||
if start_date is not None:
|
||||
period_start = start_date
|
||||
elif days is not None:
|
||||
period_start = period_end - ((days - 1) * 86400)
|
||||
else:
|
||||
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
|
||||
|
||||
if period_start > period_end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='start_date must be before end_date',
|
||||
)
|
||||
|
||||
period_days = max(1, int((period_end - period_start) / 86400) + 1)
|
||||
|
||||
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
|
||||
period_summary = await ChatMessages.get_user_usage_summary(
|
||||
user.id, period_start, period_end, timezone=user.timezone, db=db
|
||||
)
|
||||
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
|
||||
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
|
||||
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
|
||||
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
|
||||
|
||||
streaks = _calculate_streaks(heatmap)
|
||||
total_messages = period_summary.get('messages', 0)
|
||||
total_chats = chat_stats.get('total_chats', 0)
|
||||
active_days = period_summary.get('active_days', 0)
|
||||
assistant_messages = period_summary.get('assistant_messages', 0)
|
||||
user_messages = period_summary.get('user_messages', 0)
|
||||
|
||||
return UserUsageResponse(
|
||||
totals=UserUsageTotals(
|
||||
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
|
||||
input_tokens=lifetime_summary.get('input_tokens', 0),
|
||||
output_tokens=lifetime_summary.get('output_tokens', 0),
|
||||
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
|
||||
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
|
||||
current_streak=streaks['current'],
|
||||
longest_streak=streaks['longest'],
|
||||
total_chats=total_chats,
|
||||
active_days=active_days,
|
||||
models_used=lifetime_summary.get('models_used', 0),
|
||||
messages=total_messages,
|
||||
user_messages=user_messages,
|
||||
assistant_messages=assistant_messages,
|
||||
),
|
||||
heatmap=heatmap,
|
||||
weekly_heatmap=_build_weekly_heatmap(heatmap),
|
||||
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
|
||||
insights=UserUsageInsights(
|
||||
most_used_model=top_models[0]['model_id'] if top_models else None,
|
||||
average_tokens_per_chat=(
|
||||
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
|
||||
),
|
||||
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
|
||||
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
),
|
||||
top_models=top_models,
|
||||
top_tools=top_tools,
|
||||
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import logging
|
|||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import pycrdt as Y
|
||||
import socketio
|
||||
|
|
@ -16,7 +15,6 @@ from open_webui.env import (
|
|||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
REDIS_KEY_PREFIX,
|
||||
VERSION,
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
WEBSOCKET_MANAGER,
|
||||
WEBSOCKET_REDIS_CLUSTER,
|
||||
|
|
@ -33,18 +31,19 @@ from open_webui.env import (
|
|||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||
from open_webui.models.users import UserNameResponse, Users
|
||||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import create_task, stop_item_tasks
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import decode_token, is_valid_token
|
||||
from open_webui.utils.auth import get_verified_user_by_token
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.redis import (
|
||||
build_sentinel_url,
|
||||
get_redis_connection,
|
||||
get_sentinels_from_env,
|
||||
)
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -57,6 +56,12 @@ REDIS = None
|
|||
# Configure CORS for Socket.IO
|
||||
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
||||
|
||||
|
||||
def get_room_sid_map(manager, namespace: str, room: str):
|
||||
"""Return this process's Socket.IO sid map for a room, without copying it."""
|
||||
return manager.rooms.get(namespace, {}).get(room)
|
||||
|
||||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
|
||||
ws_redis_url = (
|
||||
|
|
@ -166,25 +171,31 @@ YDOC_MANAGER = YdocManager(
|
|||
|
||||
async def periodic_session_pool_cleanup():
|
||||
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
|
||||
if not session_aquire_func():
|
||||
log.debug('Session cleanup lock held by another node. Skipping.')
|
||||
return
|
||||
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
|
||||
while True:
|
||||
if not session_aquire_func():
|
||||
log.debug('Session cleanup lock held by another node. Retrying.')
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not session_renew_func():
|
||||
log.error('Unable to renew session cleanup lock. Exiting.')
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
if not session_renew_func():
|
||||
log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.')
|
||||
break
|
||||
|
||||
now = int(time.time())
|
||||
for sid in list(SESSION_POOL.keys()):
|
||||
entry = SESSION_POOL.get(sid)
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
||||
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
||||
del SESSION_POOL[sid]
|
||||
await asyncio.sleep(SESSION_POOL_TIMEOUT)
|
||||
finally:
|
||||
session_release_func()
|
||||
now = int(time.time())
|
||||
for sid in list(SESSION_POOL.keys()):
|
||||
entry = SESSION_POOL.get(sid)
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
||||
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
||||
try:
|
||||
del SESSION_POOL[sid]
|
||||
except KeyError:
|
||||
pass
|
||||
await asyncio.sleep(SESSION_POOL_TIMEOUT)
|
||||
finally:
|
||||
session_release_func()
|
||||
|
||||
|
||||
async def periodic_usage_pool_cleanup():
|
||||
|
|
@ -205,17 +216,19 @@ async def periodic_usage_pool_cleanup():
|
|||
try:
|
||||
while True:
|
||||
if not renew_func():
|
||||
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
raise Exception('Unable to renew usage pool cleanup lock.')
|
||||
|
||||
now = int(time.time())
|
||||
send_usage = False
|
||||
for model_id, connections in list(USAGE_POOL.items()):
|
||||
# Creating a list of sids to remove if they have timed out
|
||||
expired_sids = [
|
||||
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
|
||||
]
|
||||
|
||||
if connections and not expired_sids:
|
||||
continue
|
||||
|
||||
for sid in expired_sids:
|
||||
del connections[sid]
|
||||
|
||||
|
|
@ -224,8 +237,6 @@ async def periodic_usage_pool_cleanup():
|
|||
del USAGE_POOL[model_id]
|
||||
else:
|
||||
USAGE_POOL[model_id] = connections
|
||||
|
||||
send_usage = True
|
||||
await asyncio.sleep(TIMEOUT_DURATION)
|
||||
finally:
|
||||
release_func()
|
||||
|
|
@ -252,24 +263,21 @@ def get_user_id_from_session_pool(sid):
|
|||
|
||||
def get_session_ids_from_room(room):
|
||||
"""Get all session IDs from a specific room."""
|
||||
active_session_ids = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
return [session_id[0] for session_id in active_session_ids]
|
||||
members = get_room_sid_map(sio.manager, '/', room)
|
||||
return list(members) if members else []
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = get_session_ids_from_room(room)
|
||||
|
||||
# Single pool lookup per session (each .get is a Redis round trip
|
||||
# when the session pool is Redis-backed).
|
||||
active_user_ids = list(
|
||||
set(
|
||||
[
|
||||
SESSION_POOL.get(session_id)['id']
|
||||
for session_id in active_session_ids
|
||||
if SESSION_POOL.get(session_id) is not None
|
||||
]
|
||||
)
|
||||
{
|
||||
entry['id']
|
||||
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
|
||||
if entry is not None
|
||||
}
|
||||
)
|
||||
return active_user_ids
|
||||
|
||||
|
|
@ -333,7 +341,7 @@ async def usage(sid, data):
|
|||
|
||||
# Store the new usage data and task
|
||||
USAGE_POOL[model_id] = {
|
||||
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
||||
**(USAGE_POOL.get(model_id) or {}),
|
||||
sid: {'updated_at': current_time},
|
||||
}
|
||||
|
||||
|
|
@ -345,13 +353,10 @@ async def connect(sid, environ, auth):
|
|||
scope = (environ or {}).get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
data = decode_token(auth['token'])
|
||||
|
||||
if data is not None and 'id' in data and await is_valid_token(data, redis):
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
|
||||
if user:
|
||||
SESSION_POOL[sid] = {
|
||||
socket_user = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
|
|
@ -363,6 +368,8 @@ async def connect(sid, environ, auth):
|
|||
),
|
||||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
SESSION_POOL[sid] = socket_user
|
||||
await sio.save_session(sid, {'user': socket_user})
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
|
||||
|
||||
|
|
@ -376,15 +383,11 @@ async def user_join(sid, data):
|
|||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
token_data = decode_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
SESSION_POOL[sid] = {
|
||||
socket_user = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
|
|
@ -397,6 +400,8 @@ async def user_join(sid, data):
|
|||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
|
||||
SESSION_POOL[sid] = socket_user
|
||||
await sio.save_session(sid, {'user': socket_user})
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
|
||||
# Join all the channels only if user has channels permission
|
||||
|
|
@ -427,11 +432,7 @@ async def join_channel(sid, data):
|
|||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
data = decode_token(auth['token'])
|
||||
if data is None or 'id' not in data or not await is_valid_token(data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -453,11 +454,7 @@ async def join_note(sid, data):
|
|||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
token_data = decode_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -486,13 +483,7 @@ async def join_note(sid, data):
|
|||
@sio.on('events:channel')
|
||||
async def channel_events(sid, data):
|
||||
room = f'channel:{data["channel_id"]}'
|
||||
participants = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
|
||||
sids = [sid for sid, _ in participants]
|
||||
if sid not in sids:
|
||||
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
|
||||
return
|
||||
|
||||
event_data = data['data']
|
||||
|
|
@ -518,9 +509,35 @@ async def channel_events(sid, data):
|
|||
await Channels.update_member_last_read_at(data['channel_id'], user['id'])
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str) -> dict[str, int]:
|
||||
folder_list = await Folders.get_folders_by_user_id(user_id)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in folder_list}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()))
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
@sio.on('events:chat')
|
||||
async def chat_events(sid, data):
|
||||
user = SESSION_POOL.get(sid)
|
||||
try:
|
||||
session = await sio.get_session(sid)
|
||||
user = session.get('user')
|
||||
except KeyError:
|
||||
user = None
|
||||
|
||||
if not user:
|
||||
user = SESSION_POOL.get(sid)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -528,7 +545,34 @@ async def chat_events(sid, data):
|
|||
event_type = event_data.get('type')
|
||||
|
||||
if event_type == 'last_read_at':
|
||||
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
||||
read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
||||
if not read_update:
|
||||
return
|
||||
last_read_at, was_unread = read_update
|
||||
response_data = {
|
||||
'chat_id': data['chat_id'],
|
||||
'last_read_at': last_read_at,
|
||||
}
|
||||
if was_unread:
|
||||
response_data['folder_unread_counts'] = await get_folder_unread_counts(user['id'])
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
'data': {
|
||||
'type': 'chat:list',
|
||||
'data': response_data,
|
||||
},
|
||||
},
|
||||
room=f'user:{user["id"]}',
|
||||
)
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(data['chat_id'], 'chat.read', user['id'])
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
|
||||
|
||||
|
||||
def normalize_document_id(document_id: str) -> str:
|
||||
|
|
@ -829,7 +873,6 @@ async def yjs_awareness_update(sid, data):
|
|||
@sio.event
|
||||
async def disconnect(sid, reason=None):
|
||||
if sid in SESSION_POOL:
|
||||
user = SESSION_POOL[sid]
|
||||
del SESSION_POOL[sid]
|
||||
|
||||
# Clean up USAGE_POOL entries for this session
|
||||
|
|
@ -925,18 +968,27 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
user_id = request_info['user_id']
|
||||
chat_id = request_info['chat_id']
|
||||
message_id = request_info['message_id']
|
||||
internal = request_info.get('internal') is True
|
||||
save_to_chat = update_db and message_id and is_saved_chat_id(chat_id)
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
'data': event_data,
|
||||
},
|
||||
room=f'user:{user_id}',
|
||||
)
|
||||
if internal and event_data.get('type') == 'notification':
|
||||
return
|
||||
|
||||
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
|
||||
room = f'user:{user_id}'
|
||||
# Local rooms are authoritative; Redis may have listeners on another instance.
|
||||
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
**({'internal': True} if internal else {}),
|
||||
'data': event_data,
|
||||
},
|
||||
room=room,
|
||||
)
|
||||
|
||||
if save_to_chat:
|
||||
event_type = event_data.get('type')
|
||||
|
||||
if event_type == 'status':
|
||||
|
|
@ -992,6 +1044,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'embeds': embeds,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type == 'files':
|
||||
|
|
@ -1009,6 +1062,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'files': files,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type in ('source', 'citation'):
|
||||
|
|
@ -1028,6 +1082,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'sources': sources,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
|
|
@ -1059,6 +1114,11 @@ async def get_event_call(request_info):
|
|||
)
|
||||
except TimeoutError:
|
||||
log.warning(f'Event caller timed out for session {session_id}')
|
||||
if SESSION_POOL.get(session_id) == session:
|
||||
try:
|
||||
del SESSION_POOL[session_id]
|
||||
except KeyError:
|
||||
pass
|
||||
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
|
||||
|
||||
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import json
|
|||
import uuid
|
||||
|
||||
import pycrdt as Y
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.env import REDIS_KEY_PREFIX
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
||||
|
||||
|
|
@ -16,6 +16,19 @@ YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
|||
class RedisLock:
|
||||
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
|
||||
|
||||
_RENEW_SCRIPT = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('expire', KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
_RELEASE_SCRIPT = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url,
|
||||
|
|
@ -41,13 +54,10 @@ class RedisLock:
|
|||
return self.lock_obtained
|
||||
|
||||
def renew_lock(self):
|
||||
# xx=True will only set this key if it _has_ already been set
|
||||
return self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs)
|
||||
return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs))
|
||||
|
||||
def release_lock(self):
|
||||
lock_value = self.redis.get(self.lock_name)
|
||||
if lock_value and lock_value == self.lock_id:
|
||||
self.redis.delete(self.lock_name)
|
||||
self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id)
|
||||
|
||||
|
||||
class RedisDict:
|
||||
|
|
@ -102,12 +112,18 @@ class RedisDict:
|
|||
|
||||
# Serialize values once — reused for both the fingerprint and the write.
|
||||
serialized = {k: json.dumps(v) for k, v in mapping.items()}
|
||||
digest = hashlib.sha256()
|
||||
for key in sorted(serialized):
|
||||
digest.update(key.encode())
|
||||
digest.update(b'\0')
|
||||
digest.update(serialized[key].encode())
|
||||
digest.update(b'\0')
|
||||
signature = digest.hexdigest()
|
||||
|
||||
# Skip the write when the prepared mapping is identical to the last one
|
||||
# this process wrote. The check is per-instance (not distributed), but
|
||||
# still eliminates the majority of redundant writes because each pod
|
||||
# typically produces the same model list on consecutive refreshes.
|
||||
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
|
||||
if signature == self._last_signature:
|
||||
return
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -2,10 +2,8 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from open_webui.env import REDIS_KEY_PREFIX
|
||||
|
|
@ -13,7 +11,7 @@ from open_webui.env import REDIS_KEY_PREFIX
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
# A dictionary to keep track of active tasks
|
||||
tasks: Dict[str, asyncio.Task] = {}
|
||||
tasks: dict[str, asyncio.Task] = {}
|
||||
item_tasks = {}
|
||||
|
||||
|
||||
|
|
@ -46,7 +44,7 @@ async def redis_task_command_listener(app):
|
|||
### ------------------------------
|
||||
|
||||
|
||||
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||
async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '')
|
||||
if item_id:
|
||||
|
|
@ -54,7 +52,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
|||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
||||
if item_id:
|
||||
|
|
@ -67,11 +65,11 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str])
|
|||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_list_tasks(redis: Redis) -> List[str]:
|
||||
async def redis_list_tasks(redis: Redis) -> list[str]:
|
||||
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
||||
|
||||
|
||||
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
|
||||
async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]:
|
||||
return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}'))
|
||||
|
||||
|
||||
|
|
@ -101,11 +99,11 @@ async def cleanup_task(redis, task_id: str, id=None):
|
|||
item_tasks.pop(id, None)
|
||||
|
||||
|
||||
async def create_task(redis, coroutine, id=None):
|
||||
async def create_task(redis, coroutine, id=None, task_id=None):
|
||||
"""
|
||||
Create a new asyncio task and add it to the global task dictionary.
|
||||
"""
|
||||
task_id = str(uuid4()) # Generate a unique ID for the task
|
||||
task_id = task_id or str(uuid4()) # Generate a unique ID for the task
|
||||
task = asyncio.create_task(coroutine) # Create the task
|
||||
|
||||
# Add a done callback for cleanup
|
||||
|
|
@ -199,12 +197,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool:
|
|||
"""Check if a chat has any active tasks."""
|
||||
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
||||
return len(task_ids) > 0
|
||||
|
||||
|
||||
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
|
||||
"""Filter a list of chat_ids to only those with active tasks."""
|
||||
active = []
|
||||
for chat_id in chat_ids:
|
||||
if await has_active_tasks(redis, chat_id):
|
||||
active.append(chat_id)
|
||||
return active
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from open_webui.models.channels import Channel, ChannelMember, Channels
|
||||
from open_webui.models.chats import Chats
|
||||
|
|
@ -49,6 +49,11 @@ from open_webui.routers.memories import (
|
|||
add_memory as _add_memory,
|
||||
)
|
||||
from open_webui.routers.retrieval import search_web as _search_web
|
||||
from open_webui.tasks import stop_item_tasks
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.socket.main import sio
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.notifications import notify_target
|
||||
from open_webui.utils.sanitize import sanitize_code
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -56,6 +61,33 @@ log = logging.getLogger(__name__)
|
|||
MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
|
||||
|
||||
|
||||
async def _has_write_access_to_note(note, user_id: str) -> bool:
|
||||
if note.user_id == user_id:
|
||||
return True
|
||||
|
||||
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)]
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _emit_note_updated(request: Request, user: dict, note) -> None:
|
||||
await sio.emit('events:note', note.model_dump(), to=f'note:{note.id}')
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.NOTE_UPDATED,
|
||||
actor=user,
|
||||
subject_id=note.id,
|
||||
data={'title': note.title},
|
||||
)
|
||||
|
||||
|
||||
async def _has_read_access_to_file(
|
||||
file,
|
||||
user_id: str,
|
||||
|
|
@ -81,6 +113,33 @@ async def _has_read_access_to_file(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def notify(
|
||||
message: str,
|
||||
target: str = '',
|
||||
title: str = '',
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Send a notification to the user's configured notification target.
|
||||
|
||||
:param message: Notification body.
|
||||
:param target: Optional target id or name. Empty uses the default target.
|
||||
:param title: Optional notification title.
|
||||
"""
|
||||
user_id = (__user__ or {}).get('id')
|
||||
if not user_id:
|
||||
return 'Notification failed: user not found.'
|
||||
|
||||
app_name = getattr(getattr(__request__, 'app', None), 'state', None)
|
||||
app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI')
|
||||
try:
|
||||
result = await notify_target(user_id, message, target=target, title=title, app_name=app_name)
|
||||
return f'Notification sent to {result.get("target_id")}.'
|
||||
except Exception as e:
|
||||
return f'Notification failed: {e}'
|
||||
|
||||
|
||||
async def get_current_timestamp(
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
|
|
@ -320,7 +379,7 @@ async def generate_image(
|
|||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if __chat_id__ and __message_id__ and images:
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
db_files = await Chats.add_message_files_by_id_and_message_id(
|
||||
__chat_id__,
|
||||
__message_id__,
|
||||
|
|
@ -388,7 +447,7 @@ async def edit_image(
|
|||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if __chat_id__ and __message_id__ and images:
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
db_files = await Chats.add_message_files_by_id_and_message_id(
|
||||
__chat_id__,
|
||||
__message_id__,
|
||||
|
|
@ -1048,12 +1107,16 @@ async def view_note(
|
|||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
if note.user_id != user_id and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
if (
|
||||
__user__.get('role') != 'admin'
|
||||
and note.user_id != user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
|
||||
|
|
@ -1128,16 +1191,20 @@ async def write_note(
|
|||
|
||||
async def replace_note_content(
|
||||
note_id: str,
|
||||
content: str,
|
||||
content: Optional[str] = None,
|
||||
operations: Optional[list[dict]] = None,
|
||||
title: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update the markdown content, and optionally the title, of an existing note.
|
||||
Update an existing note by replacing the whole markdown content or applying range operations.
|
||||
|
||||
:param note_id: The ID of the note to update
|
||||
:param content: The new markdown content for the note
|
||||
:param content: The new markdown content for a whole-note update
|
||||
:param operations: Optional note operations:
|
||||
- {"action": "replace", "content": "..."}
|
||||
- {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."}
|
||||
:param title: Optional new title for the note
|
||||
:return: JSON with success status and updated note info
|
||||
"""
|
||||
|
|
@ -1153,25 +1220,113 @@ async def replace_note_content(
|
|||
note = await Notes.get_note_by_id(note_id)
|
||||
|
||||
if not note:
|
||||
return json.dumps({'error': 'Note not found'})
|
||||
return json.dumps({'error': 'Note not found', 'code': 'not_found'})
|
||||
|
||||
# Check write permission
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id):
|
||||
return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'})
|
||||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
current_content = ((note.data or {}).get('content') or {}).get('md') or ''
|
||||
applied_operation_count = 0
|
||||
if operations is not None:
|
||||
if not isinstance(operations, list) or len(operations) == 0:
|
||||
return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'})
|
||||
|
||||
if note.user_id != user_id and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({'error': 'Write access denied'})
|
||||
range_operations = []
|
||||
for idx, operation in enumerate(operations):
|
||||
if not isinstance(operation, dict):
|
||||
return json.dumps(
|
||||
{'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx}
|
||||
)
|
||||
|
||||
# Build update form
|
||||
update_data = {'data': {'content': {'md': content}}}
|
||||
action = operation.get('action')
|
||||
replacement = operation.get('content')
|
||||
|
||||
if action == 'replace':
|
||||
if len(operations) != 1:
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'replace operation must be the only operation',
|
||||
'code': 'invalid_operations',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
if not isinstance(replacement, str):
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'replace operation content must be a string',
|
||||
'code': 'invalid_content',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
content = replacement
|
||||
applied_operation_count = 1
|
||||
break
|
||||
|
||||
if action != 'replace_range':
|
||||
return json.dumps(
|
||||
{'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action}
|
||||
)
|
||||
|
||||
start = operation.get('start')
|
||||
end = operation.get('end')
|
||||
expected = operation.get('expected')
|
||||
if not isinstance(start, int) or not isinstance(end, int):
|
||||
return json.dumps(
|
||||
{'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx}
|
||||
)
|
||||
if not isinstance(replacement, str):
|
||||
return json.dumps(
|
||||
{'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx}
|
||||
)
|
||||
if start < 0 or end < start or end > len(current_content):
|
||||
return json.dumps(
|
||||
{'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx}
|
||||
)
|
||||
if expected is not None and current_content[start:end] != expected:
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'operation expected text does not match current content',
|
||||
'code': 'expected_mismatch',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
|
||||
range_operations.append({'start': start, 'end': end, 'content': replacement})
|
||||
|
||||
range_operations.sort(key=lambda operation: operation['start'])
|
||||
previous_end = 0
|
||||
for idx, operation in enumerate(range_operations):
|
||||
if operation['start'] < previous_end:
|
||||
return json.dumps(
|
||||
{'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx}
|
||||
)
|
||||
previous_end = operation['end']
|
||||
|
||||
if range_operations:
|
||||
content = current_content
|
||||
for operation in reversed(range_operations):
|
||||
content = content[: operation['start']] + operation['content'] + content[operation['end'] :]
|
||||
applied_operation_count = len(range_operations)
|
||||
elif content is None:
|
||||
return json.dumps({'error': 'content or operations is required', 'code': 'content_required'})
|
||||
|
||||
try:
|
||||
await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
update_data = {
|
||||
'data': {
|
||||
**(note.data or {}),
|
||||
'content': {
|
||||
**((note.data or {}).get('content') or {}),
|
||||
'json': None,
|
||||
'html': '',
|
||||
'md': content,
|
||||
},
|
||||
}
|
||||
}
|
||||
if title:
|
||||
update_data['title'] = title
|
||||
|
||||
|
|
@ -1179,7 +1334,9 @@ async def replace_note_content(
|
|||
updated_note = await Notes.update_note_by_id(note_id, form)
|
||||
|
||||
if not updated_note:
|
||||
return json.dumps({'error': 'Failed to update note'})
|
||||
return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'})
|
||||
|
||||
await _emit_note_updated(__request__, __user__, updated_note)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
|
|
@ -1187,12 +1344,13 @@ async def replace_note_content(
|
|||
'id': updated_note.id,
|
||||
'title': updated_note.title,
|
||||
'updated_at': updated_note.updated_at,
|
||||
'applied_operation_count': applied_operation_count,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'replace_note_content error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
return json.dumps({'error': str(e), 'code': 'unexpected_error'})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -1348,6 +1506,85 @@ async def view_chat(
|
|||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUB-AGENT TOOL
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def delegate_task(
|
||||
task: str,
|
||||
context: str = '',
|
||||
background: bool = False,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
__chat_id__: str = None,
|
||||
__message_id__: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delegate focused work to a parallel sub-agent using the current model and tools.
|
||||
|
||||
:param task: The specific task for the sub-agent to complete
|
||||
:param context: Relevant context, decisions, or file paths for the task
|
||||
:param background: Return immediately and continue this chat when the sub-agent finishes
|
||||
:return: Foreground result text, or a JSON dispatch handle for background work
|
||||
"""
|
||||
if __request__ is None:
|
||||
return 'Error: request context not available.'
|
||||
if getattr(__request__.state, 'internal', False) is True:
|
||||
return 'Error: sub-agents cannot delegate recursively.'
|
||||
|
||||
from open_webui.utils.subagents import delegate
|
||||
|
||||
return await delegate(
|
||||
task,
|
||||
context,
|
||||
background,
|
||||
request=__request__,
|
||||
user_data=__user__ or {},
|
||||
metadata=__metadata__ or {},
|
||||
parent_chat_id=__chat_id__ or '',
|
||||
parent_message_id=__message_id__,
|
||||
)
|
||||
|
||||
|
||||
async def timer(
|
||||
prompt: str,
|
||||
at: str,
|
||||
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
__chat_id__: str = None,
|
||||
__message_id__: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Set a one-shot timer for this chat.
|
||||
|
||||
:param prompt: The prompt to send back into this chat when the timer fires
|
||||
:param at: Relative time like 10s, 5m, 1h, 2d, or a timezone-aware RFC 3339 timestamp
|
||||
:param cancel_on: Optional events that cancel the timer before it fires
|
||||
:return: JSON status with the scheduled time, or an error string
|
||||
"""
|
||||
if __request__ is None:
|
||||
return 'Error: request context not available.'
|
||||
if getattr(__request__.state, 'internal', False) is True:
|
||||
return 'Error: timers cannot be set from internal chats.'
|
||||
|
||||
from open_webui.utils.timers import create_timer
|
||||
|
||||
return await create_timer(
|
||||
prompt=prompt,
|
||||
at=at,
|
||||
cancel_on=cancel_on,
|
||||
request=__request__,
|
||||
user_data=__user__ or {},
|
||||
metadata=__metadata__ or {},
|
||||
parent_chat_id=__chat_id__ or '',
|
||||
parent_message_id=__message_id__,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHANNELS TOOLS
|
||||
# =============================================================================
|
||||
|
|
@ -1925,6 +2162,309 @@ DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
|
|||
MAX_GREP_RESULTS = 50
|
||||
|
||||
|
||||
async def _get_accessible_chat_files(
|
||||
files: Optional[list[dict]],
|
||||
user: dict,
|
||||
file_id: Optional[str] = None,
|
||||
) -> list[tuple[dict, object]]:
|
||||
from open_webui.models.files import Files
|
||||
|
||||
user_id = user.get('id')
|
||||
user_role = user.get('role', 'user')
|
||||
accessible = []
|
||||
seen = set()
|
||||
|
||||
for item in files or []:
|
||||
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
|
||||
continue
|
||||
fid = item.get('id') or item.get('url') or ''
|
||||
if (
|
||||
not isinstance(fid, str)
|
||||
or not fid
|
||||
or fid in seen
|
||||
or fid.startswith(('http://', 'https://', 'data:'))
|
||||
or (file_id and fid != file_id)
|
||||
):
|
||||
continue
|
||||
normalized = {**item, 'id': fid, 'type': 'file'}
|
||||
if 'name' not in normalized and item.get('filename'):
|
||||
normalized['name'] = item.get('filename')
|
||||
seen.add(fid)
|
||||
|
||||
file = await Files.get_file_by_id(fid)
|
||||
if file and await _has_read_access_to_file(file, user_id, user_role):
|
||||
accessible.append((normalized, file))
|
||||
|
||||
return accessible
|
||||
|
||||
|
||||
def _grep_file_models(
|
||||
files_to_search: list,
|
||||
pattern: str,
|
||||
case_insensitive: bool = False,
|
||||
count_only: bool = False,
|
||||
) -> str:
|
||||
from open_webui.tools.knowledge_fs import build_matcher
|
||||
|
||||
matches, err = build_matcher(pattern, case_insensitive)
|
||||
if err:
|
||||
return json.dumps({'error': err})
|
||||
|
||||
results = []
|
||||
total_matches = 0
|
||||
counts = []
|
||||
|
||||
for file in files_to_search:
|
||||
content = ''
|
||||
if file.data:
|
||||
content = file.data.get('content', '')
|
||||
if not content:
|
||||
continue
|
||||
|
||||
lines = content.split('\n')
|
||||
file_matches = 0
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if matches(line):
|
||||
file_matches += 1
|
||||
total_matches += 1
|
||||
if not count_only and len(results) < MAX_GREP_RESULTS:
|
||||
results.append(f'{file.id} {file.filename}:{i}: {line}')
|
||||
|
||||
if file_matches > 0 and count_only:
|
||||
counts.append(f'{file.id} {file.filename}: {file_matches}')
|
||||
|
||||
if count_only:
|
||||
if not counts:
|
||||
return f'No matches for "{pattern}"'
|
||||
return '\n'.join(counts) + f'\n[{total_matches} total matches]'
|
||||
|
||||
if not results:
|
||||
return f'No matches for "{pattern}"'
|
||||
|
||||
output = '\n'.join(results)
|
||||
if total_matches > MAX_GREP_RESULTS:
|
||||
output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]'
|
||||
return output
|
||||
|
||||
|
||||
async def list_chat_files(
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__files__: list[dict] = None,
|
||||
) -> str:
|
||||
"""
|
||||
List files attached to the current chat.
|
||||
|
||||
:return: JSON with attached chat files containing id, filename, content type, size, and updated time when available
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
try:
|
||||
files = []
|
||||
for item, file in await _get_accessible_chat_files(__files__, __user__):
|
||||
file_info = {
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'name': item.get('name') or file.filename,
|
||||
'type': item.get('type', 'file'),
|
||||
'updated_at': file.updated_at,
|
||||
}
|
||||
content_type = item.get('content_type') or (file.meta or {}).get('content_type')
|
||||
size = item.get('size') or (file.meta or {}).get('size')
|
||||
if content_type:
|
||||
file_info['content_type'] = content_type
|
||||
if size:
|
||||
file_info['size'] = size
|
||||
files.append(file_info)
|
||||
|
||||
return json.dumps(files, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
log.exception(f'list_chat_files error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def grep_chat_files(
|
||||
pattern: str,
|
||||
file_id: Optional[str] = None,
|
||||
case_insensitive: bool = False,
|
||||
count_only: bool = False,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__files__: list[dict] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search exact text across files attached to the current chat.
|
||||
Pass file_id from the attached_files block to search one file.
|
||||
|
||||
:param pattern: The text pattern to search for
|
||||
:param file_id: Optional attached file ID to search within a single file
|
||||
:param case_insensitive: If true, ignore case when matching
|
||||
:param count_only: If true, return only match counts per file
|
||||
:return: Matching lines with file IDs, filenames, and line numbers
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
if not pattern or not pattern.strip():
|
||||
return json.dumps({'error': 'Pattern is required'})
|
||||
|
||||
if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''):
|
||||
file_id = None
|
||||
|
||||
try:
|
||||
attached_ids = set()
|
||||
for item in __files__ or []:
|
||||
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
|
||||
continue
|
||||
fid = item.get('id') or item.get('url')
|
||||
if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')):
|
||||
attached_ids.add(fid)
|
||||
|
||||
if not attached_ids:
|
||||
return json.dumps({'error': 'No files are attached to this chat'})
|
||||
if file_id and file_id not in attached_ids:
|
||||
return json.dumps({'error': 'File not found'})
|
||||
|
||||
files_to_search = [file for _, file in await _get_accessible_chat_files(__files__, __user__, file_id)]
|
||||
if not files_to_search:
|
||||
return json.dumps({'error': 'No accessible files found'})
|
||||
|
||||
return _grep_file_models(files_to_search, pattern, case_insensitive, count_only)
|
||||
except Exception as e:
|
||||
log.exception(f'grep_chat_files error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def query_chat_files(
|
||||
query: str,
|
||||
file_id: Optional[str] = None,
|
||||
count: Optional[int] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__files__: list[dict] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search files attached to the current chat using semantic/vector search.
|
||||
Pass file_id from the attached_files block to search one file, or omit it to search all attached chat files.
|
||||
|
||||
:param query: The search query to find semantically relevant content
|
||||
:param file_id: Optional attached file ID to search within a single file
|
||||
:param count: Maximum number of results to return, capped by the server RAG top k
|
||||
:return: JSON with relevant chunks containing content, source filename, and relevance score
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''):
|
||||
file_id = None
|
||||
if isinstance(count, str):
|
||||
if count.lower() in ('none', 'null', ''):
|
||||
count = None
|
||||
else:
|
||||
try:
|
||||
count = int(count)
|
||||
except ValueError:
|
||||
count = None
|
||||
|
||||
try:
|
||||
from open_webui.retrieval.utils import get_sources_from_items
|
||||
|
||||
attached_ids = set()
|
||||
for item in __files__ or []:
|
||||
if not isinstance(item, dict) or item.get('type', 'file') != 'file':
|
||||
continue
|
||||
fid = item.get('id') or item.get('url')
|
||||
if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')):
|
||||
attached_ids.add(fid)
|
||||
|
||||
if not attached_ids:
|
||||
return json.dumps({'error': 'No files are attached to this chat'})
|
||||
if file_id and file_id not in attached_ids:
|
||||
return json.dumps({'error': 'File not found'})
|
||||
|
||||
accessible = await _get_accessible_chat_files(__files__, __user__, file_id)
|
||||
if not accessible:
|
||||
return json.dumps({'error': 'No accessible files found'})
|
||||
|
||||
file_items = [{**item} for item, _ in accessible]
|
||||
rag_config = await Config.get_many(
|
||||
'rag.top_k',
|
||||
'rag.top_k_reranker',
|
||||
'rag.relevance_threshold',
|
||||
'rag.hybrid_bm25_weight',
|
||||
'rag.enable_hybrid_search',
|
||||
'rag.full_context',
|
||||
)
|
||||
top_k = rag_config.get('rag.top_k') or 5
|
||||
count = top_k if count is None else max(1, min(count, top_k))
|
||||
full_context = all(item.get('context') == 'full' for item in file_items) or rag_config.get('rag.full_context')
|
||||
|
||||
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
|
||||
if not embedding_function and not full_context:
|
||||
return json.dumps({'error': 'Embedding function not configured'})
|
||||
|
||||
user_model = UserModel.model_construct(
|
||||
id=__user__.get('id'),
|
||||
role=__user__.get('role', 'user'),
|
||||
)
|
||||
sources = await get_sources_from_items(
|
||||
request=__request__,
|
||||
items=file_items,
|
||||
queries=[query],
|
||||
embedding_function=(
|
||||
lambda queries, prefix: embedding_function(queries, prefix=prefix, user=user_model)
|
||||
if embedding_function
|
||||
else None
|
||||
),
|
||||
k=count,
|
||||
reranking_function=(
|
||||
(lambda q, docs: __request__.app.state.RERANKING_FUNCTION(q, docs, user=user_model))
|
||||
if getattr(__request__.app.state, 'RERANKING_FUNCTION', None)
|
||||
else None
|
||||
),
|
||||
k_reranker=rag_config.get('rag.top_k_reranker'),
|
||||
r=rag_config.get('rag.relevance_threshold'),
|
||||
hybrid_bm25_weight=rag_config.get('rag.hybrid_bm25_weight'),
|
||||
hybrid_search=rag_config.get('rag.enable_hybrid_search'),
|
||||
full_context=full_context,
|
||||
user=user_model,
|
||||
)
|
||||
|
||||
chunks = []
|
||||
for source in sources or []:
|
||||
documents = source.get('document') or []
|
||||
metadatas = source.get('metadata') or []
|
||||
distances = source.get('distances') or []
|
||||
source_info = source.get('source') or {}
|
||||
|
||||
for idx, doc in enumerate(documents):
|
||||
metadata = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
|
||||
chunk = {
|
||||
'content': doc,
|
||||
'source': metadata.get('source', metadata.get('name', source_info.get('name', 'Unknown'))),
|
||||
'file_id': metadata.get('file_id', source_info.get('id', '')),
|
||||
}
|
||||
if idx < len(distances):
|
||||
chunk['distance'] = distances[idx]
|
||||
chunks.append(chunk)
|
||||
|
||||
return json.dumps(chunks[:count], ensure_ascii=False)
|
||||
except Exception as e:
|
||||
log.exception(f'query_chat_files error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def grep_knowledge_files(
|
||||
pattern: str,
|
||||
file_id: Optional[str] = None,
|
||||
|
|
@ -1958,16 +2498,11 @@ async def grep_knowledge_files(
|
|||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.tools.knowledge_fs import build_matcher
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
|
||||
_matches, err = build_matcher(pattern, case_insensitive)
|
||||
if err:
|
||||
return json.dumps({'error': err})
|
||||
|
||||
# Collect files to search
|
||||
files_to_search = []
|
||||
|
||||
|
|
@ -2043,43 +2578,7 @@ async def grep_knowledge_files(
|
|||
if not files_to_search:
|
||||
return json.dumps({'error': 'No accessible files found'})
|
||||
|
||||
# Search
|
||||
results = []
|
||||
total_matches = 0
|
||||
counts = []
|
||||
|
||||
for file in files_to_search:
|
||||
content = ''
|
||||
if file.data:
|
||||
content = file.data.get('content', '')
|
||||
if not content:
|
||||
continue
|
||||
|
||||
lines = content.split('\n')
|
||||
file_matches = 0
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
file_matches += 1
|
||||
total_matches += 1
|
||||
if not count_only and len(results) < MAX_GREP_RESULTS:
|
||||
results.append(f'{file.id} {file.filename}:{i}: {line}')
|
||||
|
||||
if file_matches > 0 and count_only:
|
||||
counts.append(f'{file.id} {file.filename}: {file_matches}')
|
||||
|
||||
if count_only:
|
||||
if not counts:
|
||||
return f'No matches for "{pattern}"'
|
||||
return '\n'.join(counts) + f'\n[{total_matches} total matches]'
|
||||
|
||||
if not results:
|
||||
return f'No matches for "{pattern}"'
|
||||
|
||||
output = '\n'.join(results)
|
||||
if total_matches > MAX_GREP_RESULTS:
|
||||
output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]'
|
||||
return output
|
||||
return _grep_file_models(files_to_search, pattern, case_insensitive, count_only)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f'grep_knowledge_files error: {e}')
|
||||
|
|
@ -2928,8 +3427,8 @@ async def create_tasks(
|
|||
:param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated).
|
||||
:return: JSON with the full task list and summary counts
|
||||
"""
|
||||
if __chat_id__ is None:
|
||||
return json.dumps({'error': 'Chat context not available'})
|
||||
if not is_saved_chat_id(__chat_id__):
|
||||
return json.dumps({'error': 'Saved chat context not available'})
|
||||
|
||||
try:
|
||||
all_tasks = []
|
||||
|
|
@ -2980,8 +3479,8 @@ async def update_task(
|
|||
:param status: New status: completed, in_progress, pending, or cancelled (default: completed)
|
||||
:return: JSON with the updated task list and summary counts
|
||||
"""
|
||||
if __chat_id__ is None:
|
||||
return json.dumps({'error': 'Chat context not available'})
|
||||
if not is_saved_chat_id(__chat_id__):
|
||||
return json.dumps({'error': 'Saved chat context not available'})
|
||||
|
||||
try:
|
||||
status = status.strip().lower()
|
||||
|
|
@ -3019,10 +3518,22 @@ async def update_task(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def _validate_owned_automation_folder(user_id: str, folder_id: Optional[str]) -> Optional[str]:
|
||||
if not folder_id:
|
||||
return None
|
||||
from open_webui.models.folders import Folders
|
||||
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user_id)
|
||||
if not folder:
|
||||
raise ValueError('Folder not found')
|
||||
return folder.id
|
||||
|
||||
|
||||
async def create_automation(
|
||||
name: str,
|
||||
prompt: str,
|
||||
rrule: str,
|
||||
folder_id: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
|
|
@ -3045,6 +3556,7 @@ async def create_automation(
|
|||
:param name: A short descriptive name for the automation
|
||||
:param prompt: The prompt/instructions to execute on each run
|
||||
:param rrule: An iCalendar RRULE string defining the schedule
|
||||
:param folder_id: Optional owner-owned folder ID for generated chats
|
||||
:return: JSON with the created automation details including id, next scheduled runs
|
||||
"""
|
||||
if __request__ is None:
|
||||
|
|
@ -3056,6 +3568,7 @@ async def create_automation(
|
|||
try:
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, Automations
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.routers.automations import check_automation_limits
|
||||
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
|
||||
|
||||
user_id = __user__.get('id')
|
||||
|
|
@ -3071,15 +3584,26 @@ async def create_automation(
|
|||
if not model_id:
|
||||
return json.dumps({'error': 'Could not detect current model'})
|
||||
|
||||
try:
|
||||
folder_id = await _validate_owned_automation_folder(user_id, folder_id)
|
||||
except ValueError as e:
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
# Validate the RRULE
|
||||
try:
|
||||
validate_rrule(rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
return json.dumps({'error': f'Invalid schedule: {e}'})
|
||||
|
||||
try:
|
||||
await check_automation_limits(__request__, user, rrule, None, is_create=True)
|
||||
except HTTPException as e:
|
||||
return json.dumps({'error': e.detail})
|
||||
|
||||
tz = user.timezone
|
||||
form = AutomationForm(
|
||||
name=name,
|
||||
folder_id=folder_id,
|
||||
data=AutomationData(
|
||||
prompt=prompt,
|
||||
model_id=model_id,
|
||||
|
|
@ -3095,6 +3619,7 @@ async def create_automation(
|
|||
'status': 'success',
|
||||
'id': automation.id,
|
||||
'name': automation.name,
|
||||
'folder_id': automation.folder_id,
|
||||
'model_id': model_id,
|
||||
'is_active': automation.is_active,
|
||||
'next_runs': next_n_runs_ns(rrule, tz=tz),
|
||||
|
|
@ -3112,6 +3637,7 @@ async def update_automation(
|
|||
prompt: Optional[str] = None,
|
||||
rrule: Optional[str] = None,
|
||||
model_id: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
|
|
@ -3123,6 +3649,7 @@ async def update_automation(
|
|||
:param prompt: New prompt/instructions (optional)
|
||||
:param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples.
|
||||
:param model_id: New model ID to use (optional)
|
||||
:param folder_id: New owner-owned folder ID (optional); pass an empty string to clear
|
||||
:return: JSON with the updated automation details
|
||||
"""
|
||||
if __request__ is None:
|
||||
|
|
@ -3134,10 +3661,13 @@ async def update_automation(
|
|||
try:
|
||||
from open_webui.models.automations import AutomationData, AutomationForm, Automations
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.routers.automations import check_automation_limits
|
||||
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user = await Users.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return json.dumps({'error': 'User not found'})
|
||||
|
||||
automation = await Automations.get_by_id(automation_id)
|
||||
if not automation:
|
||||
|
|
@ -3150,17 +3680,30 @@ async def update_automation(
|
|||
new_prompt = prompt if prompt is not None else automation.data.get('prompt', '')
|
||||
new_model_id = model_id if model_id is not None else automation.data.get('model_id', '')
|
||||
new_rrule = rrule if rrule is not None else automation.data.get('rrule', '')
|
||||
if folder_id is None:
|
||||
new_folder_id = automation.folder_id
|
||||
else:
|
||||
try:
|
||||
new_folder_id = await _validate_owned_automation_folder(user_id, folder_id)
|
||||
except ValueError as e:
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
# Validate RRULE if changed
|
||||
if rrule is not None:
|
||||
try:
|
||||
validate_rrule(new_rrule, tz=user.timezone if user else None)
|
||||
validate_rrule(new_rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
return json.dumps({'error': f'Invalid schedule: {e}'})
|
||||
|
||||
tz = user.timezone if user else None
|
||||
try:
|
||||
await check_automation_limits(__request__, user, new_rrule, None)
|
||||
except HTTPException as e:
|
||||
return json.dumps({'error': e.detail})
|
||||
|
||||
tz = user.timezone
|
||||
form = AutomationForm(
|
||||
name=new_name,
|
||||
folder_id=new_folder_id,
|
||||
data=AutomationData(
|
||||
prompt=new_prompt,
|
||||
model_id=new_model_id,
|
||||
|
|
@ -3176,6 +3719,7 @@ async def update_automation(
|
|||
'status': 'success',
|
||||
'id': updated.id,
|
||||
'name': updated.name,
|
||||
'folder_id': updated.folder_id,
|
||||
'model_id': new_model_id,
|
||||
'is_active': updated.is_active,
|
||||
'next_runs': next_n_runs_ns(new_rrule, tz=tz),
|
||||
|
|
@ -3189,6 +3733,7 @@ async def update_automation(
|
|||
|
||||
async def list_automations(
|
||||
status: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
count: int = 10,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
|
|
@ -3197,6 +3742,7 @@ async def list_automations(
|
|||
List the user's scheduled automations.
|
||||
|
||||
:param status: Filter by status: "active", "paused", or omit for all
|
||||
:param folder_id: Optional owner-owned folder ID filter; pass an empty string to clear the folder filter
|
||||
:param count: Maximum number of automations to return (default: 10)
|
||||
:return: JSON list of automations with id, name, prompt snippet, schedule, status, and next runs
|
||||
"""
|
||||
|
|
@ -3213,10 +3759,16 @@ async def list_automations(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user = await Users.get_user_by_id(user_id)
|
||||
if folder_id:
|
||||
try:
|
||||
folder_id = await _validate_owned_automation_folder(user_id, folder_id)
|
||||
except ValueError as e:
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
result = await Automations.search_automations(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
folder_id=folder_id,
|
||||
skip=0,
|
||||
limit=count,
|
||||
)
|
||||
|
|
@ -3231,6 +3783,7 @@ async def list_automations(
|
|||
{
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'folder_id': item.folder_id,
|
||||
'prompt_snippet': snippet,
|
||||
'model_id': item.data.get('model_id', ''),
|
||||
'rrule': rrule,
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ MAX_GREP_MATCHES = 50
|
|||
|
||||
|
||||
def is_regex_pattern(pattern: str) -> bool:
|
||||
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
|
||||
"""Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...])."""
|
||||
return (
|
||||
'\|' in pattern
|
||||
'|' in pattern
|
||||
or '.*' in pattern
|
||||
or '.+' in pattern
|
||||
or '.?' in pattern
|
||||
|
|
@ -686,12 +686,16 @@ async def _kb_grep(
|
|||
|
||||
# Grep on piped input
|
||||
if piped_input is not None:
|
||||
lines = piped_input.split('\\n')
|
||||
lines = piped_input.split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
matched.append(f'{i}: {line}')
|
||||
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
if count_only:
|
||||
return str(len(matched))
|
||||
if filenames_only:
|
||||
return '(standard input)' if matched else f'No matches for "{pattern}"'
|
||||
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
|
||||
# Single file grep
|
||||
if file_ref and not dir_scope:
|
||||
|
|
@ -702,7 +706,7 @@ async def _kb_grep(
|
|||
elif 'error' in resolved:
|
||||
return resolved['error']
|
||||
else:
|
||||
lines = resolved['content'].split('\\n')
|
||||
lines = resolved['content'].split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
|
|
@ -715,7 +719,7 @@ async def _kb_grep(
|
|||
|
||||
if not matched:
|
||||
return f'No matches for "{pattern}" in {resolved["filename"]}'
|
||||
return '\\n'.join(matched)
|
||||
return '\n'.join(matched)
|
||||
|
||||
# Cross-file grep (optionally scoped to directory)
|
||||
accessible = await _get_accessible_files(user, model_knowledge)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from typing import Any
|
|||
|
||||
from open_webui.config import DEFAULT_USER_PERMISSIONS
|
||||
from open_webui.models.access_grants import (
|
||||
has_anyone_read_access_grant,
|
||||
has_public_read_access_grant,
|
||||
has_public_write_access_grant,
|
||||
has_user_access_grant,
|
||||
strip_anyone_access_grants,
|
||||
strip_user_access_grants,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
@ -215,13 +217,31 @@ async def filter_allowed_access_grants(
|
|||
user_role: str,
|
||||
access_grants: list,
|
||||
public_permission_key: str,
|
||||
anyone_permission_key: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Checks if the user has the required permissions to grant access to a resource.
|
||||
Returns the filtered list of access grants if permissions are missing.
|
||||
"""
|
||||
if user_role == 'admin' or not access_grants:
|
||||
if not access_grants:
|
||||
return access_grants
|
||||
|
||||
if has_anyone_read_access_grant(access_grants) and (
|
||||
not anyone_permission_key
|
||||
or (
|
||||
user_role != 'admin'
|
||||
and not await has_permission(
|
||||
user_id,
|
||||
anyone_permission_key,
|
||||
default_permissions,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
access_grants = strip_anyone_access_grants(access_grants)
|
||||
|
||||
if user_role == 'admin':
|
||||
return access_grants
|
||||
|
||||
# Check if user can share publicly
|
||||
|
|
@ -253,6 +273,22 @@ async def filter_allowed_access_grants(
|
|||
):
|
||||
access_grants = strip_user_access_grants(access_grants)
|
||||
|
||||
if any(
|
||||
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
|
||||
for grant in access_grants
|
||||
) and not await has_permission(
|
||||
user_id,
|
||||
'access_grants.allow_groups',
|
||||
default_permissions,
|
||||
db=db,
|
||||
):
|
||||
access_grants = [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
!= 'group'
|
||||
]
|
||||
|
||||
return access_grants
|
||||
|
||||
|
||||
|
|
@ -260,6 +296,7 @@ async def has_base_model_access(
|
|||
user_id: str,
|
||||
model_info,
|
||||
*,
|
||||
user_role: str | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
db=None,
|
||||
) -> bool:
|
||||
|
|
@ -267,9 +304,11 @@ async def has_base_model_access(
|
|||
Walk the ``base_model_id`` chain and verify the caller has read access
|
||||
at every hop.
|
||||
|
||||
Returns ``True`` when access is granted (or the chain ends at a raw
|
||||
provider model that has no per-model ACL). Returns ``False`` the
|
||||
moment a registered base model denies access.
|
||||
A base model without a ``model`` table row is admin-only, matching how
|
||||
unregistered models are treated for direct use (``get_filtered_models``
|
||||
hides them from non-admins and ``check_model_access`` rejects them), so
|
||||
a shared preset cannot be used to reach a base model the caller could
|
||||
not use directly. Returns ``False`` the moment any hop denies access.
|
||||
"""
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.models import Models
|
||||
|
|
@ -280,7 +319,7 @@ async def has_base_model_access(
|
|||
seen.add(base_model_id)
|
||||
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
|
||||
if base_model_info is None:
|
||||
break # Raw provider model — no per-model ACL
|
||||
return user_role == 'admin'
|
||||
if not (
|
||||
user_id == base_model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
|
|
@ -339,7 +378,7 @@ async def check_model_access(
|
|||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
|
||||
if not await has_base_model_access(user.id, model_info, user_role=user.role, user_group_ids=user_group_ids):
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ async def has_access_to_file(
|
|||
access_type: str,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified access to a file through any of:
|
||||
|
|
@ -43,7 +44,8 @@ async def has_access_to_file(
|
|||
# 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)}
|
||||
if user_group_ids is None:
|
||||
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
|
||||
|
|
@ -60,12 +62,25 @@ async def has_access_to_file(
|
|||
|
||||
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 and (
|
||||
access_type == 'read' or knowledge_base.user_id == file.user_id
|
||||
):
|
||||
return True
|
||||
# Fetch the one referenced knowledge base instead of listing every
|
||||
# knowledge base the user can access just to scan for this id.
|
||||
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
|
||||
if (
|
||||
knowledge_base
|
||||
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
|
||||
and (
|
||||
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,
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
|
|
@ -88,7 +103,9 @@ async def has_access_to_file(
|
|||
|
||||
# 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):
|
||||
for model in await Models.get_models_by_user_id(
|
||||
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
|
||||
):
|
||||
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:
|
||||
|
|
@ -105,14 +122,18 @@ async def get_accessible_folder_files(
|
|||
) -> list[dict]:
|
||||
"""Filter folder.data['files'] entries to those the caller can read.
|
||||
|
||||
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
|
||||
Admins bypass all checks. Unknown types are kept as-is.
|
||||
Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and
|
||||
note ids are each access-checked against the caller; admins bypass all checks and
|
||||
genuinely unknown types are kept as-is.
|
||||
"""
|
||||
if not entries:
|
||||
return []
|
||||
if user.role == 'admin':
|
||||
return list(entries)
|
||||
|
||||
# One group-membership fetch for the whole folder listing
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
|
|
@ -123,11 +144,27 @@ async def get_accessible_folder_files(
|
|||
accessible.append(entry)
|
||||
continue
|
||||
if entry_type == 'file':
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db):
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'note':
|
||||
# Owner has no self-grant (notes are private by default), so check ownership too.
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
note = await Notes.get_note_by_id(entry_id, db=db)
|
||||
if note and (
|
||||
note.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=entry_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
accessible.append(entry)
|
||||
else:
|
||||
accessible.append(entry)
|
||||
return accessible
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import sys
|
|||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
from open_webui.utils.middleware import process_tool_result
|
||||
from open_webui.utils.models import get_all_models
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
|
|||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise Exception('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
if '.' in action_id:
|
||||
action_id, sub_action_id = action_id.split('.')
|
||||
else:
|
||||
|
|
@ -43,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
|||
raise Exception('Model not found')
|
||||
model = models[model_id]
|
||||
|
||||
# Availability gate — keep this route consistent with the actions a model
|
||||
# actually surfaces to the client. Executing admin-authored Function code is
|
||||
# intended; this only stops a disabled, unassigned, or access-restricted
|
||||
# action from being reached by calling the route with a raw action_id.
|
||||
if action.type != 'action' or not action.is_active:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
# Direct connections carry a client-supplied model the caller already owns,
|
||||
# so scope the model-bound checks to server-resolved models.
|
||||
if not getattr(request.state, 'direct', False) and user.role != 'admin':
|
||||
await check_model_access(user, model)
|
||||
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
|
||||
# the function id is always the prefix.
|
||||
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
|
||||
if action_id not in surfaced_action_ids:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
__event_emitter__ = await get_event_emitter(
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
|
||||
'model',
|
||||
'messages',
|
||||
'system',
|
||||
'max_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop_sequences',
|
||||
'stream',
|
||||
'metadata',
|
||||
'service_tier',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'reasoning_effort',
|
||||
}
|
||||
|
||||
|
||||
def is_anthropic_url(url: str) -> bool:
|
||||
"""Check if the URL is an Anthropic API endpoint."""
|
||||
|
|
@ -109,7 +126,16 @@ def _finalize_openai_content(blocks: list) -> str | list:
|
|||
return blocks
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool:
|
||||
api_config = api_config or {}
|
||||
provider = str(api_config.get('provider', '')).lower()
|
||||
|
||||
return is_anthropic_url(url or '') or provider == 'litellm'
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(
|
||||
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
|
||||
|
|
@ -173,6 +199,8 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
},
|
||||
)
|
||||
)
|
||||
elif block_type in ('thinking', 'redacted_thinking'):
|
||||
openai_content.append(_copy_cache_control(block, dict(block)))
|
||||
elif block_type == 'image':
|
||||
source = block.get('source', {})
|
||||
if source.get('type') == 'base64':
|
||||
|
|
@ -350,6 +378,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
if 'max_tokens' in anthropic_payload:
|
||||
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
|
||||
|
||||
captured_passthrough_params = {
|
||||
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
|
||||
}
|
||||
if isinstance(passthrough_params, str):
|
||||
passthrough_params = passthrough_params.split(',')
|
||||
elif not isinstance(passthrough_params, (list, tuple, set)):
|
||||
passthrough_params = []
|
||||
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
|
||||
if '*' in passthrough_param_names:
|
||||
openai_payload.update(captured_passthrough_params)
|
||||
else:
|
||||
for param in passthrough_param_names:
|
||||
if param in captured_passthrough_params:
|
||||
openai_payload[param] = captured_passthrough_params[param]
|
||||
|
||||
output_config = anthropic_payload.get('output_config')
|
||||
if isinstance(output_config, dict):
|
||||
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = output_config['effort']
|
||||
|
||||
format_config = output_config.get('format')
|
||||
if isinstance(format_config, dict):
|
||||
format_type = format_config.get('type')
|
||||
if format_type == 'json_schema':
|
||||
json_schema = {
|
||||
'name': format_config.get('name', 'response_schema'),
|
||||
'schema': format_config.get('schema', {}),
|
||||
}
|
||||
if 'description' in format_config:
|
||||
json_schema['description'] = format_config['description']
|
||||
if 'strict' in format_config:
|
||||
json_schema['strict'] = format_config['strict']
|
||||
openai_payload['response_format'] = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': json_schema,
|
||||
}
|
||||
elif format_type == 'json_object':
|
||||
openai_payload['response_format'] = {'type': format_type}
|
||||
|
||||
if 'reasoning_effort' in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
|
||||
|
||||
# Common parameters
|
||||
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
|
||||
if param in anthropic_payload:
|
||||
|
|
@ -395,7 +465,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
return openai_payload
|
||||
|
||||
|
||||
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
|
||||
def convert_openai_to_anthropic_response(
|
||||
openai_response: dict, model: str = '', input_tokens: int | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
|
||||
"""
|
||||
|
|
@ -419,6 +491,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
|
||||
# Build content blocks
|
||||
content = []
|
||||
message_thinking = message.get('thinking')
|
||||
thinking_blocks = message.get('thinking_blocks') or []
|
||||
if not thinking_blocks and isinstance(message_thinking, dict):
|
||||
thinking_blocks = message_thinking.get('blocks') or []
|
||||
|
||||
has_thinking = False
|
||||
for block in thinking_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
if block.get('type') == 'redacted_thinking':
|
||||
content.append({k: v for k, v in block.items() if k in {'type', 'data'}})
|
||||
has_thinking = True
|
||||
continue
|
||||
|
||||
thinking = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if not thinking:
|
||||
continue
|
||||
|
||||
thinking_block = {'type': 'thinking', 'thinking': thinking}
|
||||
if block.get('signature'):
|
||||
thinking_block['signature'] = block['signature']
|
||||
content.append(thinking_block)
|
||||
has_thinking = True
|
||||
|
||||
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
|
||||
if not reasoning_content and isinstance(message_thinking, str):
|
||||
reasoning_content = message_thinking
|
||||
if reasoning_content and not has_thinking:
|
||||
content.append({'type': 'thinking', 'thinking': reasoning_content})
|
||||
|
||||
message_content = message.get('content')
|
||||
if message_content:
|
||||
content.append({'type': 'text', 'text': message_content})
|
||||
|
|
@ -443,8 +546,12 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
# Usage
|
||||
openai_usage = openai_response.get('usage', {})
|
||||
usage = {
|
||||
'input_tokens': openai_usage.get('prompt_tokens', 0),
|
||||
'output_tokens': openai_usage.get('completion_tokens', 0),
|
||||
'input_tokens': (
|
||||
input_tokens
|
||||
if input_tokens is not None
|
||||
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0))
|
||||
),
|
||||
'output_tokens': openai_usage.get('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']
|
||||
|
|
@ -463,7 +570,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
}
|
||||
|
||||
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int = 0):
|
||||
"""
|
||||
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
|
||||
|
||||
|
|
@ -480,13 +587,13 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
import uuid as _uuid
|
||||
|
||||
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stop_reason = 'end_turn'
|
||||
|
||||
# Track content blocks with a running index.
|
||||
# Each text block or tool_use block gets its own index.
|
||||
current_block_index = 0
|
||||
thinking_block_open = False
|
||||
text_block_open = False
|
||||
|
||||
# Accumulated state for each tool call, keyed by tool call id.
|
||||
|
|
@ -510,7 +617,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
'model': model,
|
||||
'stop_reason': None,
|
||||
'stop_sequence': None,
|
||||
'usage': {'input_tokens': 0, 'output_tokens': 0},
|
||||
'usage': {'input_tokens': input_tokens, 'output_tokens': 0},
|
||||
},
|
||||
}
|
||||
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
|
||||
|
|
@ -541,8 +648,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
if not choices:
|
||||
# Check for usage in the final chunk
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
input_tokens = data['usage'].get(
|
||||
'input_tokens', data['usage'].get('prompt_tokens', input_tokens)
|
||||
)
|
||||
output_tokens = data['usage'].get(
|
||||
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
|
||||
)
|
||||
continue
|
||||
|
||||
delta = choices[0].get('delta', {})
|
||||
|
|
@ -551,14 +662,57 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
|
||||
# Update usage if present
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
input_tokens = data['usage'].get('input_tokens', data['usage'].get('prompt_tokens', input_tokens))
|
||||
output_tokens = data['usage'].get(
|
||||
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
|
||||
)
|
||||
|
||||
reasoning_content = (
|
||||
delta.get('reasoning_content')
|
||||
or delta.get('reasoning')
|
||||
or delta.get('thinking')
|
||||
or message.get('reasoning_content')
|
||||
or message.get('reasoning')
|
||||
)
|
||||
if not reasoning_content:
|
||||
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
|
||||
for block in thinking_blocks:
|
||||
if isinstance(block, dict):
|
||||
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if reasoning_content:
|
||||
break
|
||||
|
||||
if reasoning_content and not text_block_open and not has_tool_calls:
|
||||
if not thinking_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {'type': 'thinking', 'thinking': ''},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
thinking_block_open = True
|
||||
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': current_block_index,
|
||||
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# --- 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 and not has_tool_calls:
|
||||
if thinking_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()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if not text_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
|
|
@ -584,6 +738,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if thinking_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()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if text_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
|
|
@ -695,6 +858,12 @@ 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}')
|
||||
|
||||
# Close any open thinking block
|
||||
if thinking_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()
|
||||
current_block_index += 1
|
||||
|
||||
# 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']:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ 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
|
||||
|
|
@ -101,14 +100,20 @@ class CommitSessionMiddleware:
|
|||
# Downstream did not complete successfully. Roll back any
|
||||
# pending sync writes, release the connection, and let the
|
||||
# exception propagate.
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
if ScopedSession.registry.has():
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
raise
|
||||
|
||||
# Nothing in this request touched the sync session: committing would
|
||||
# only instantiate one to run an empty transaction.
|
||||
if not ScopedSession.registry.has():
|
||||
return
|
||||
|
||||
# Downstream completed. Commit pending sync work.
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
|
|
@ -138,9 +143,7 @@ class AuthTokenMiddleware:
|
|||
the middleware checks that instead and avoids the 401 short-circuit.
|
||||
|
||||
Routes that depend on `get_verified_user` etc. read this state.
|
||||
Also exposes `request.state.enable_api_keys` (snapshotted at request
|
||||
entry from runtime config) and stamps an `X-Process-Time` response
|
||||
header.
|
||||
Also stamps an `X-Process-Time` response header.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
|
||||
|
|
@ -166,13 +169,12 @@ class AuthTokenMiddleware:
|
|||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
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':
|
||||
process_time = int(time.monotonic() - start_time)
|
||||
process_time = time.monotonic() - start_time
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers['X-Process-Time'] = str(process_time)
|
||||
headers['X-Process-Time'] = f'{process_time:.6f}'
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_timing)
|
||||
|
|
@ -231,7 +233,15 @@ class RedirectMiddleware:
|
|||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
raw_query = scope.get('query_string', b'')
|
||||
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
|
||||
# decode + parse_qs work for every other GET. (A false positive on the
|
||||
# substring check just falls through to the full parse below.)
|
||||
if not (path.endswith('/watch') or b'shared' in raw_query):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
query_string = raw_query.decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
|
||||
redirect_params: dict[str, str] = {}
|
||||
|
|
|
|||
|
|
@ -132,14 +132,27 @@ class AuditLoggingMiddleware:
|
|||
) -> None:
|
||||
self.app = app
|
||||
self.audit_logger = AuditLogger(logger)
|
||||
self.excluded_paths = excluded_paths or []
|
||||
self.included_paths = included_paths or []
|
||||
|
||||
def normalize_paths(paths: Optional[list[str]]) -> list[str]:
|
||||
return [path for path in (path.strip().lstrip('/') for path in paths or []) if path]
|
||||
|
||||
self.excluded_paths = normalize_paths(excluded_paths)
|
||||
self.included_paths = normalize_paths(included_paths)
|
||||
self.max_body_size = max_body_size
|
||||
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
|
||||
if audit_get_requests:
|
||||
self.audited_methods.add('GET')
|
||||
self.audit_level = audit_level
|
||||
|
||||
# Paths are fixed for the process lifetime; compile once instead of
|
||||
# per request. None means the corresponding mode has nothing to match.
|
||||
self._included_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None
|
||||
)
|
||||
self._excluded_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None
|
||||
)
|
||||
|
||||
if self.included_paths and self.excluded_paths:
|
||||
logger.warning(
|
||||
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
|
||||
|
|
@ -196,6 +209,13 @@ class AuditLoggingMiddleware:
|
|||
await self._log_audit_entry(request, context)
|
||||
|
||||
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
|
||||
# get_current_user stashes the resolved user on the scope-backed state;
|
||||
# reuse it instead of running the full auth pipeline (JWT decode, Redis
|
||||
# revocation checks, DB fetch, last-active write) a second time.
|
||||
user = getattr(request.state, 'user', None)
|
||||
if isinstance(user, UserModel):
|
||||
return user
|
||||
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
try:
|
||||
|
|
@ -206,6 +226,12 @@ class AuditLoggingMiddleware:
|
|||
|
||||
return None
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = (
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
)
|
||||
|
||||
def _should_skip_auditing(self, request: Request) -> bool:
|
||||
if AUDIT_LOG_LEVEL == 'NONE':
|
||||
return True
|
||||
|
|
@ -213,13 +239,8 @@ class AuditLoggingMiddleware:
|
|||
if request.method not in self.audited_methods:
|
||||
return True
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = {
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
}
|
||||
path = request.url.path.lower()
|
||||
for endpoint in ALWAYS_LOG_ENDPOINTS:
|
||||
for endpoint in self.ALWAYS_LOG_ENDPOINTS:
|
||||
if path.startswith(endpoint):
|
||||
return False # Do NOT skip logging for auth endpoints
|
||||
|
||||
|
|
@ -229,15 +250,11 @@ class AuditLoggingMiddleware:
|
|||
return True
|
||||
|
||||
# Whitelist mode: only log paths that match included_paths
|
||||
if self.included_paths:
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
|
||||
if not pattern.match(request.url.path):
|
||||
return True # Skip: path not in whitelist
|
||||
return False # Do NOT skip: path is in whitelist
|
||||
if self._included_pattern:
|
||||
return not self._included_pattern.match(request.url.path)
|
||||
|
||||
# Blacklist mode: skip paths that match excluded_paths
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
|
||||
if pattern.match(request.url.path):
|
||||
if self._excluded_pattern and self._excluded_pattern.match(request.url.path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -96,11 +96,15 @@ def get_license_data(app, key):
|
|||
setattr(app.state, 'LICENSE_METADATA', v)
|
||||
|
||||
def handler(u):
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
try:
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as ex:
|
||||
log.error(f'License: retrieval issue from {u}: {ex}')
|
||||
return False
|
||||
|
||||
if getattr(res, 'ok', False):
|
||||
payload = getattr(res, 'json', lambda: {})()
|
||||
|
|
@ -352,6 +356,8 @@ async def get_current_user(
|
|||
current_span.set_attribute('client.user.role', user.role)
|
||||
current_span.set_attribute('client.auth.type', 'api_key')
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
# auth by jwt token
|
||||
|
|
@ -399,9 +405,10 @@ async def get_current_user(
|
|||
|
||||
# Refresh the user's last active timestamp
|
||||
# Fire-and-forget via asyncio.create_task to avoid blocking
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(Users.update_last_active_by_id(user.id))
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -433,24 +440,30 @@ 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', '')
|
||||
config_values = await Config.get_many(
|
||||
'auth.enable_api_keys',
|
||||
'user.permissions',
|
||||
'auth.api_key.endpoint_restrictions',
|
||||
'auth.api_key.allowed_endpoints',
|
||||
)
|
||||
|
||||
if not request.state.enable_api_keys or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(
|
||||
if not config_values.get('auth.enable_api_keys'):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
if user.role != 'admin':
|
||||
user_permissions = config_values.get('user.permissions')
|
||||
if not await has_permission(
|
||||
user.id,
|
||||
'features.api_keys',
|
||||
user_permissions,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
# 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 enable_endpoint_restrictions:
|
||||
if config_values.get('auth.api_key.endpoint_restrictions'):
|
||||
allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '')
|
||||
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)
|
||||
|
|
@ -475,8 +488,11 @@ async def get_current_user_by_api_key(request, api_key: str):
|
|||
return user
|
||||
|
||||
|
||||
VERIFIED_USER_ROLES = {'user', 'admin'}
|
||||
|
||||
|
||||
def get_verified_user(user=Depends(get_current_user)):
|
||||
if user.role not in {'user', 'admin'}:
|
||||
if user.role not in VERIFIED_USER_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
|
|
@ -484,6 +500,19 @@ def get_verified_user(user=Depends(get_current_user)):
|
|||
return user
|
||||
|
||||
|
||||
async def get_verified_user_by_token(token: str, redis=None):
|
||||
"""Resolve a verified user from a raw token, for WebSocket handshakes that run outside the HTTP dependency chain."""
|
||||
decoded = decode_token(token)
|
||||
if decoded is None or 'id' not in decoded or not await is_valid_token(decoded, redis):
|
||||
return None
|
||||
|
||||
user = await Users.get_user_by_id(decoded['id'])
|
||||
if user is None or user.role not in VERIFIED_USER_ROLES:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_admin_user(user=Depends(get_current_user)):
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
|
|||
The scheduler_worker_loop handles all time-based background work:
|
||||
- Automation execution (claim_due → execute)
|
||||
- Calendar event alerts (upcoming events → socket + webhook notifications)
|
||||
- One-shot chat timers
|
||||
|
||||
Environment:
|
||||
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
|
||||
TIMER_POLL_INTERVAL – seconds between timer polls (default: 1)
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
|
||||
"""
|
||||
|
||||
|
|
@ -32,15 +34,18 @@ 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.folders import Folders
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.auth import create_token
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.task import prompt_template
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
|
||||
TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1'))
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
|
||||
|
||||
|
||||
|
|
@ -65,7 +70,7 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
|
|||
return None
|
||||
|
||||
|
||||
def _parse_rule(s: str):
|
||||
def _parse_rule(s: str, now: Optional[datetime] = None):
|
||||
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
|
||||
|
||||
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
||||
|
|
@ -77,6 +82,20 @@ def _parse_rule(s: str):
|
|||
|
||||
if freq in ('MINUTELY', 'HOURLY'):
|
||||
epoch = datetime(2000, 1, 1, 0, 0, 0)
|
||||
if (
|
||||
now is not None
|
||||
and s.startswith('RRULE:')
|
||||
and '\n' not in s
|
||||
and '\r' not in s
|
||||
and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'}
|
||||
):
|
||||
try:
|
||||
interval = int(parts.get('INTERVAL', '1'))
|
||||
if interval > 0:
|
||||
step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval)
|
||||
return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return rrulestr(s, dtstart=epoch, ignoretz=True)
|
||||
return rrulestr(s, ignoretz=True)
|
||||
|
||||
|
|
@ -88,12 +107,12 @@ def validate_rrule(s: str, tz: str = None) -> None:
|
|||
clock so that near-future schedules are not incorrectly rejected
|
||||
on servers whose system clock is ahead (e.g. UTC vs US timezones).
|
||||
"""
|
||||
try:
|
||||
rule = _parse_rule(s)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
try:
|
||||
rule = _parse_rule(s, now)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if rule.after(now) is None:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
|
||||
|
||||
|
|
@ -102,7 +121,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
|||
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi) if zi else datetime.now()
|
||||
dt = _parse_rule(s).after(now.replace(tzinfo=None))
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
dt = _parse_rule(s, now_naive).after(now_naive)
|
||||
if dt is None:
|
||||
return None
|
||||
if zi:
|
||||
|
|
@ -117,9 +137,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
|
|||
preview matches the user's local clock (same as next_run_ns).
|
||||
"""
|
||||
zi = _resolve_tz(tz)
|
||||
rule = _parse_rule(s)
|
||||
result = []
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
dt = now
|
||||
for _ in range(n):
|
||||
dt = rule.after(dt)
|
||||
|
|
@ -141,8 +161,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
|
|||
"""
|
||||
if 'COUNT=1' in s:
|
||||
return None
|
||||
rule = _parse_rule(s)
|
||||
now = datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
first = rule.after(now)
|
||||
if first is None:
|
||||
return None
|
||||
|
|
@ -173,9 +193,30 @@ async def scheduler_worker_loop(app) -> None:
|
|||
Runs on every instance. Poll interval is configurable via
|
||||
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
|
||||
"""
|
||||
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
|
||||
log.info(
|
||||
f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, '
|
||||
f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)'
|
||||
)
|
||||
next_scheduler_poll = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
# ── Timers ──
|
||||
try:
|
||||
from open_webui.utils.timers import claim_due_timers, execute_due_timer
|
||||
|
||||
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
|
||||
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
|
||||
except Exception:
|
||||
log.exception('Scheduler: timer error')
|
||||
|
||||
if now < next_scheduler_poll:
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
continue
|
||||
# Jitter to spread automation/calendar load across instances; timers keep a tight poll.
|
||||
next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)
|
||||
|
||||
# ── Automations ──
|
||||
if await Config.get('automations.enable'):
|
||||
try:
|
||||
|
|
@ -198,8 +239,7 @@ async def scheduler_worker_loop(app) -> None:
|
|||
except Exception:
|
||||
log.exception('Scheduler worker error')
|
||||
|
||||
# Jitter to spread load across instances
|
||||
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
|
||||
|
||||
##########################
|
||||
|
|
@ -323,16 +363,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
|
|||
log.warning(f'Terminal server {server_id} not found for CWD set')
|
||||
return
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return
|
||||
|
||||
# Build target URL — route through orchestrator policy if configured
|
||||
policy_id = connection.get('policy_id')
|
||||
if connection.get('server_type') == 'orchestrator' and policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/files/cwd'
|
||||
else:
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
|
||||
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
|
||||
if chat_id:
|
||||
|
|
@ -396,7 +431,10 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
|||
|
||||
prompt = await prompt_template(automation.data['prompt'], user)
|
||||
model_id = automation.data['model_id']
|
||||
terminal_config = automation.data.get('terminal')
|
||||
folder_id = automation.folder_id
|
||||
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
|
||||
await Automations.clear_folder_ids(automation.user_id, [folder_id])
|
||||
folder_id = None
|
||||
|
||||
# Generate proper UUIDs for messages (same as frontend)
|
||||
user_msg_id = str(uuid4())
|
||||
|
|
@ -407,6 +445,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
|||
chat_id,
|
||||
automation.user_id,
|
||||
ChatForm(
|
||||
folder_id=folder_id,
|
||||
chat={
|
||||
'title': automation.name,
|
||||
'models': [model_id],
|
||||
|
|
@ -438,7 +477,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
|||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'meta': {'automation_id': automation.id},
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -613,40 +652,25 @@ async def _check_calendar_alerts(app) -> None:
|
|||
except Exception:
|
||||
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
|
||||
|
||||
# Send webhook notification if user has one configured
|
||||
# Send target notification if user has one configured
|
||||
try:
|
||||
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
if enable_user_webhooks:
|
||||
user = await Users.get_user_by_id(event.user_id)
|
||||
if user and user.settings:
|
||||
webhook_url = (
|
||||
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
|
||||
if isinstance(user.settings, dict)
|
||||
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
|
||||
'notifications', {}
|
||||
).get('webhook_url', None)
|
||||
if hasattr(user.settings, 'ui')
|
||||
else None
|
||||
)
|
||||
if webhook_url:
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await post_webhook(
|
||||
webui_name,
|
||||
webhook_url,
|
||||
f'{event.title} — starting {time_str}',
|
||||
{
|
||||
'action': 'calendar_alert',
|
||||
'title': event.title,
|
||||
'minutes_until': minutes_until,
|
||||
'event_id': event.id,
|
||||
},
|
||||
)
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CALENDAR_ALERT,
|
||||
subject_id=event.id,
|
||||
subject_type='calendar.event',
|
||||
source='scheduler',
|
||||
data={
|
||||
**alert_data,
|
||||
'user_id': event.user_id,
|
||||
'starts_in': time_str,
|
||||
'message': f'{event.title}: starting {time_str}',
|
||||
},
|
||||
message=event.title,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
|
||||
log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True)
|
||||
|
||||
|
||||
async def _record_run(
|
||||
|
|
|
|||
|
|
@ -179,8 +179,10 @@ async def generate_chat_completion(
|
|||
# Merge the direct connection model into server models so that
|
||||
# task functions (title, tags, etc.) can resolve a server-side
|
||||
# task model while still having the direct model available.
|
||||
# dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}``
|
||||
# would issue HKEYS plus one HGET per model.
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
**dict(request.app.state.MODELS.items()),
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
log.debug(f'direct connection to model: {request.state.model["id"]}')
|
||||
|
|
@ -188,11 +190,12 @@ async def generate_chat_completion(
|
|||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data['model']
|
||||
if model_id not in models:
|
||||
# Single lookup — membership check plus getitem would be two Redis
|
||||
# round trips on a Redis-backed model pool.
|
||||
model = models.get(model_id)
|
||||
if model is None:
|
||||
raise Exception('Model not found')
|
||||
|
||||
model = models[model_id]
|
||||
|
||||
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
|
||||
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
|
||||
else:
|
||||
|
|
|
|||
41
backend/open_webui/utils/chat_fork.py
Normal file
41
backend/open_webui/utils/chat_fork.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from copy import deepcopy
|
||||
|
||||
|
||||
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
|
||||
if not messages_map:
|
||||
raise ValueError('chat has no messages to fork')
|
||||
|
||||
branch: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
message_id = source_message_id
|
||||
|
||||
while message_id:
|
||||
if message_id in seen:
|
||||
raise ValueError('message branch contains a cycle')
|
||||
seen.add(message_id)
|
||||
|
||||
message = messages_map.get(message_id)
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError('message not found')
|
||||
|
||||
branch.append((message_id, message))
|
||||
message_id = message.get('parentId')
|
||||
|
||||
fork_messages: dict[str, dict] = {}
|
||||
ordered_messages: list[dict] = []
|
||||
parent_id = None
|
||||
|
||||
for message_id, message in reversed(branch):
|
||||
copied = deepcopy(message)
|
||||
copied['id'] = message_id
|
||||
copied['parentId'] = parent_id
|
||||
copied['childrenIds'] = []
|
||||
|
||||
if parent_id:
|
||||
fork_messages[parent_id]['childrenIds'] = [message_id]
|
||||
|
||||
fork_messages[message_id] = copied
|
||||
ordered_messages.append(copied)
|
||||
parent_id = message_id
|
||||
|
||||
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages
|
||||
27
backend/open_webui/utils/chat_id.py
Normal file
27
backend/open_webui/utils/chat_id.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from typing import Optional
|
||||
|
||||
|
||||
TEMPORARY_CHAT_ID_PREFIX = 'temporary:'
|
||||
LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:' # Legacy temporary chat prefix.
|
||||
CHANNEL_CHAT_ID_PREFIX = 'channel:'
|
||||
|
||||
TEMPORARY_CHAT_ID_PREFIXES = (
|
||||
TEMPORARY_CHAT_ID_PREFIX,
|
||||
LEGACY_TEMPORARY_CHAT_ID_PREFIX,
|
||||
)
|
||||
NON_SAVED_CHAT_ID_PREFIXES = (*TEMPORARY_CHAT_ID_PREFIXES, CHANNEL_CHAT_ID_PREFIX)
|
||||
|
||||
|
||||
def is_saved_chat_id(chat_id: Optional[str]) -> bool:
|
||||
return bool(chat_id) and not chat_id.startswith(NON_SAVED_CHAT_ID_PREFIXES)
|
||||
|
||||
|
||||
def is_temporary_chat_id(chat_id: Optional[str]) -> bool:
|
||||
return bool(chat_id) and chat_id.startswith(TEMPORARY_CHAT_ID_PREFIXES)
|
||||
|
||||
|
||||
def get_temporary_chat_session_id(chat_id: str) -> Optional[str]:
|
||||
for prefix in TEMPORARY_CHAT_ID_PREFIXES:
|
||||
if chat_id.startswith(prefix):
|
||||
return chat_id.removeprefix(prefix)
|
||||
return None
|
||||
288
backend/open_webui/utils/chat_variables.py
Normal file
288
backend/open_webui/utils/chat_variables.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$')
|
||||
CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
|
||||
USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
|
||||
MAX_VARIABLE_VALUE_LENGTH = 20_000
|
||||
MAX_VARIABLES_JSON_LENGTH = 100_000
|
||||
|
||||
|
||||
class ChatVariablesError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def split_properties(value: str, delimiter: str) -> list[str]:
|
||||
result: list[str] = []
|
||||
current = ''
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for char in value:
|
||||
if escape_next:
|
||||
current += char
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
current += char
|
||||
escape_next = True
|
||||
continue
|
||||
|
||||
if char == '"' and not escape_next:
|
||||
in_string = not in_string
|
||||
current += char
|
||||
continue
|
||||
|
||||
if not in_string:
|
||||
if char in ('{', '['):
|
||||
depth += 1
|
||||
elif char in ('}', ']'):
|
||||
depth -= 1
|
||||
|
||||
if char == delimiter and depth == 0:
|
||||
result.append(current.strip())
|
||||
current = ''
|
||||
continue
|
||||
|
||||
current += char
|
||||
|
||||
if current.strip():
|
||||
result.append(current.strip())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_json_value(value: str) -> Any:
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
return value[1:-1]
|
||||
|
||||
if re.match(r'^[\[{]', value):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_variable_definition(definition: str) -> dict[str, Any]:
|
||||
parts = split_properties(definition, ':')
|
||||
if not parts:
|
||||
return {'type': 'text'}
|
||||
|
||||
first_part, *property_parts = parts
|
||||
field_type = first_part[5:] if first_part.startswith('type=') else first_part
|
||||
field_type = field_type.strip() or 'text'
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
for part in property_parts:
|
||||
trimmed = part.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
|
||||
equals_parts = split_properties(trimmed, '=')
|
||||
if len(equals_parts) == 1:
|
||||
properties[equals_parts[0].strip()] = True
|
||||
continue
|
||||
|
||||
property_name, *value_parts = equals_parts
|
||||
properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip())
|
||||
|
||||
return {'type': field_type, **properties}
|
||||
|
||||
|
||||
def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed_keys = {
|
||||
'default',
|
||||
'label',
|
||||
'max',
|
||||
'maxlength',
|
||||
'min',
|
||||
'minlength',
|
||||
'options',
|
||||
'placeholder',
|
||||
'required',
|
||||
'step',
|
||||
'type',
|
||||
}
|
||||
field = {'key': key}
|
||||
for field_key in allowed_keys:
|
||||
if field_key in definition:
|
||||
field[field_key] = definition[field_key]
|
||||
|
||||
field.setdefault('type', 'text')
|
||||
if field.get('type') == 'select' and not isinstance(field.get('options'), list):
|
||||
field['options'] = []
|
||||
field['required'] = bool(field.get('required', False))
|
||||
|
||||
return field
|
||||
|
||||
|
||||
def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None:
|
||||
if not system_prompt:
|
||||
return None
|
||||
|
||||
try:
|
||||
fields_by_key = collect_chat_variable_fields(system_prompt)
|
||||
except ChatVariablesError:
|
||||
fields_by_key = {}
|
||||
|
||||
if not fields_by_key:
|
||||
return None
|
||||
|
||||
return {'fields': list(fields_by_key.values())}
|
||||
|
||||
|
||||
def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]:
|
||||
fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
if not system_prompt:
|
||||
return fields_by_key
|
||||
|
||||
typed_fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt):
|
||||
key = match.group(1).strip()
|
||||
definition = match.group(2)
|
||||
if not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
raise ChatVariablesError(f'Invalid chat variable key: {key}')
|
||||
|
||||
if definition is None or not definition.strip():
|
||||
fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'}))
|
||||
continue
|
||||
|
||||
field = _safe_field(key, parse_variable_definition(definition.strip()))
|
||||
|
||||
if field.get('type') == 'select' and not field.get('options'):
|
||||
raise ChatVariablesError(f'Chat variable {key} select needs options.')
|
||||
previous = typed_fields_by_key.get(key)
|
||||
if previous and previous != field:
|
||||
raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.')
|
||||
typed_fields_by_key[key] = field
|
||||
fields_by_key[key] = field
|
||||
|
||||
return fields_by_key
|
||||
|
||||
|
||||
def normalize_chat_variables(variables: Any) -> dict[str, Any]:
|
||||
if not isinstance(variables, dict):
|
||||
return {}
|
||||
return variables
|
||||
|
||||
|
||||
def normalize_user_variables(variables: Any) -> dict[str, str]:
|
||||
if not isinstance(variables, dict):
|
||||
return {}
|
||||
return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)}
|
||||
|
||||
|
||||
def validate_user_variables(variables: Any) -> dict[str, str]:
|
||||
if not isinstance(variables, dict):
|
||||
raise ChatVariablesError('User variables must be an object.')
|
||||
|
||||
try:
|
||||
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('User variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('User variables must be JSON serializable.')
|
||||
|
||||
validated: dict[str, str] = {}
|
||||
for key, value in variables.items():
|
||||
if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
raise ChatVariablesError(f'Invalid user variable key: {key}')
|
||||
if not isinstance(value, str):
|
||||
raise ChatVariablesError(f'User variable must be a string: {key}')
|
||||
value = value.replace('\r\n', '\n')
|
||||
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
|
||||
raise ChatVariablesError(f'User variable is too long: {key}')
|
||||
validated[key] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def validate_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
field_map = collect_chat_variable_fields(system_prompt)
|
||||
variables = normalize_chat_variables(variables)
|
||||
|
||||
try:
|
||||
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('Chat variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('Chat variables must be JSON serializable.')
|
||||
|
||||
validated: dict[str, Any] = {}
|
||||
for key, field in field_map.items():
|
||||
has_value = key in variables and variables[key] not in (None, '')
|
||||
value = variables.get(key)
|
||||
|
||||
if not has_value:
|
||||
if field.get('default') not in (None, ''):
|
||||
value = field.get('default')
|
||||
has_value = True
|
||||
elif required and field.get('required'):
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Missing required chat variable: {label}')
|
||||
else:
|
||||
value = ''
|
||||
|
||||
if field.get('type') == 'select':
|
||||
options = field.get('options') or []
|
||||
if has_value and value not in options:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Invalid value for chat variable: {label}')
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.replace('\r\n', '\n')
|
||||
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Chat variable is too long: {label}')
|
||||
|
||||
validated[key] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def render_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> str | None:
|
||||
if not system_prompt:
|
||||
return system_prompt
|
||||
|
||||
try:
|
||||
validated = validate_chat_variables(system_prompt, variables, required=required)
|
||||
except ChatVariablesError:
|
||||
validated = {}
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = validated.get(key, '')
|
||||
return '' if value is None else str(value)
|
||||
|
||||
return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt)
|
||||
|
||||
|
||||
def render_user_variables(system_prompt: str | None, variables: Any) -> str | None:
|
||||
if not system_prompt:
|
||||
return system_prompt
|
||||
|
||||
variables = normalize_user_variables(variables)
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
if not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
return ''
|
||||
return variables.get(key, '')
|
||||
|
||||
return USER_VARIABLE_ANY_RE.sub(replace, system_prompt)
|
||||
|
|
@ -5,9 +5,9 @@ import logging
|
|||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list
|
||||
from open_webui.utils.task import (
|
||||
get_task_model_id,
|
||||
|
|
@ -53,11 +53,11 @@ async def compact_messages_for_request(
|
|||
return messages, None, False
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
token_threshold = _resolve_token_threshold(config['token_threshold'], metadata)
|
||||
token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata)
|
||||
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
|
||||
return messages, previous_summary, False
|
||||
|
||||
boundary = _find_compaction_boundary(messages)
|
||||
boundary = _find_compaction_boundary(messages, config['retention_percentage'])
|
||||
compacted_messages = messages[:boundary]
|
||||
recent_messages = messages[boundary:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
|
|
@ -108,12 +108,15 @@ async def compact_messages_for_request(
|
|||
raise
|
||||
|
||||
chat_id = metadata.get('chat_id')
|
||||
checkpoint_message_id = metadata.get('user_message_id') or metadata.get('message_id')
|
||||
if chat_id and checkpoint_message_id and not chat_id.startswith(('local:', 'channel:')):
|
||||
checkpoint_message_id = (
|
||||
recent_messages[0].get('id') or metadata.get('user_message_id') or metadata.get('message_id')
|
||||
)
|
||||
if is_saved_chat_id(chat_id) and checkpoint_message_id:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
checkpoint_message_id,
|
||||
{'contextSummary': summary},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
log.info(
|
||||
|
|
@ -146,8 +149,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
if not config['enable']:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
chat_data = chat.chat or {}
|
||||
history = chat_data.get('history') or {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'empty'}
|
||||
|
||||
|
|
@ -156,11 +164,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
messages_map = history.get('messages') or {}
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id))
|
||||
if len(messages) <= 2:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
compacted_messages = messages[:-1]
|
||||
recent_messages = messages[-1:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
summary = await _generate_summary(
|
||||
request,
|
||||
user,
|
||||
|
|
@ -171,7 +179,9 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
previous_summary,
|
||||
config['prompt_template'],
|
||||
)
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(chat.id, current_id, {'contextSummary': summary})
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat.id, current_id, {'contextSummary': summary}, touch=False
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
|
|
@ -186,11 +196,16 @@ async def _load_config() -> dict:
|
|||
values = await Config.get_many(
|
||||
'chat.context_compaction.enable',
|
||||
'chat.context_compaction.token_threshold',
|
||||
'chat.context_compaction.token_cap',
|
||||
'chat.context_compaction.retention_percentage',
|
||||
'chat.context_compaction.prompt_template',
|
||||
)
|
||||
token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000
|
||||
return {
|
||||
'enable': bool(values.get('chat.context_compaction.enable', False)),
|
||||
'token_threshold': int(values.get('chat.context_compaction.token_threshold', 80000) or 80000),
|
||||
'token_threshold': token_threshold,
|
||||
'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold,
|
||||
'retention_percentage': _clamp_retention_percentage(values.get('chat.context_compaction.retention_percentage')),
|
||||
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
|
||||
}
|
||||
|
||||
|
|
@ -203,11 +218,81 @@ def _parse_positive_int(value: Any) -> int | None:
|
|||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _resolve_token_threshold(global_threshold: int, metadata: dict) -> int:
|
||||
def _clamp_retention_percentage(value: Any) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = 40
|
||||
return min(50, max(10, parsed))
|
||||
|
||||
|
||||
def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int:
|
||||
configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold'))
|
||||
if configured_threshold is None:
|
||||
return global_threshold
|
||||
return min(configured_threshold, global_threshold)
|
||||
return min(configured_threshold or global_threshold, global_cap)
|
||||
|
||||
|
||||
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
|
||||
chat_data = chat.chat or {}
|
||||
history = chat_data.get('history') or {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return None
|
||||
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat.id)
|
||||
messages = get_message_list(messages_map or history.get('messages') or {}, current_id)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
config = await _load_config()
|
||||
if not config['enable']:
|
||||
return None
|
||||
|
||||
params = ((chat.chat or {}).get('params') or {}).copy()
|
||||
if model_id:
|
||||
params['model'] = model_id
|
||||
threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params})
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
|
||||
if isinstance(usage, dict) and (
|
||||
tokens := (
|
||||
int(
|
||||
usage.get('prompt_tokens')
|
||||
or usage.get('input_tokens')
|
||||
or usage.get('prompt_eval_count')
|
||||
or usage.get('prompt_n')
|
||||
or 0
|
||||
)
|
||||
+ int(
|
||||
usage.get('completion_tokens')
|
||||
or usage.get('output_tokens')
|
||||
or usage.get('eval_count')
|
||||
or usage.get('predicted_n')
|
||||
or 0
|
||||
)
|
||||
+ int(usage.get('cache_n') or 0)
|
||||
)
|
||||
):
|
||||
tokens += _estimate_messages_tokens(messages[idx + 1 :])
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages)
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
|
||||
def _build_context_usage(tokens: int, threshold: int) -> dict:
|
||||
return {
|
||||
'tokens': tokens,
|
||||
'estimated_tokens': tokens,
|
||||
'threshold': threshold,
|
||||
'percent': round((tokens / threshold) * 100) if threshold > 0 else 0,
|
||||
'source': 'estimated',
|
||||
}
|
||||
|
||||
|
||||
def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]:
|
||||
|
|
@ -231,27 +316,37 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
|
|||
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
|
||||
if isinstance(usage, dict) and usage.get('input_tokens'):
|
||||
total = int(usage.get('input_tokens') or 0) + int(usage.get('output_tokens') or 0)
|
||||
return total + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
|
||||
if isinstance(usage, dict) and (
|
||||
tokens := (
|
||||
int(
|
||||
usage.get('prompt_tokens')
|
||||
or usage.get('input_tokens')
|
||||
or usage.get('prompt_eval_count')
|
||||
or usage.get('prompt_n')
|
||||
or 0
|
||||
)
|
||||
+ int(
|
||||
usage.get('completion_tokens')
|
||||
or usage.get('output_tokens')
|
||||
or usage.get('eval_count')
|
||||
or usage.get('predicted_n')
|
||||
or 0
|
||||
)
|
||||
+ int(usage.get('cache_n') or 0)
|
||||
)
|
||||
):
|
||||
return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
|
||||
|
||||
estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages)
|
||||
return estimated > threshold
|
||||
|
||||
|
||||
def _find_compaction_boundary(messages: list[dict]) -> int:
|
||||
keep_count = max(2, len(messages) * 2 // 5)
|
||||
split = max(1, len(messages) - keep_count)
|
||||
|
||||
while split < len(messages) - 1:
|
||||
previous = messages[split - 1] if split > 0 else {}
|
||||
current = messages[split]
|
||||
if current.get('role') == 'tool' or previous.get('tool_calls') or previous.get('output'):
|
||||
split += 1
|
||||
continue
|
||||
break
|
||||
|
||||
return min(split, len(messages) - 2)
|
||||
def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int:
|
||||
retention_percentage = _clamp_retention_percentage(retention_percentage)
|
||||
keep_count = max(2, len(messages) * retention_percentage // 100)
|
||||
target = max(1, len(messages) - keep_count)
|
||||
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
|
||||
return next((idx for idx in reversed(boundaries) if idx <= target), 0)
|
||||
|
||||
|
||||
async def _generate_summary(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import re
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
|
|
@ -75,7 +76,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
|||
# file-ID resolver which enforces ownership/access checks.
|
||||
return await get_image_base64_from_file_id(url, user=user)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -200,15 +201,15 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]:
|
|||
|
||||
# Check if the file already exists in the cache
|
||||
if file_path.is_file():
|
||||
with open(file_path, 'rb') as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
||||
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
||||
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
async with aiofiles.open(file_path, 'rb') as image_file:
|
||||
encoded_string = base64.b64encode(await image_file.read()).decode('utf-8')
|
||||
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
||||
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
||||
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import inspect
|
||||
import logging
|
||||
|
||||
from open_webui.env import ENABLE_PLUGINS
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
load_function_module_by_id,
|
||||
)
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -19,22 +17,15 @@ async def get_function_module(request, function_id, load_from_db=True):
|
|||
|
||||
|
||||
async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
|
||||
async def get_priority(function_id):
|
||||
try:
|
||||
function_module = await get_function_module(request, function_id)
|
||||
if function_module and hasattr(function_module, 'Valves'):
|
||||
valves_db = await Functions.get_function_valves_by_id(function_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, 'priority', 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
filter_ids = [function.id for function in await Functions.get_global_filter_functions()]
|
||||
active_filters = await Functions.get_active_filter_ids()
|
||||
filter_ids = [fid for fid, is_global in active_filters if is_global]
|
||||
if 'info' in model and 'meta' in model['info']:
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
filter_ids = list(set(filter_ids))
|
||||
active_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
|
||||
active_filter_ids = {fid for fid, _ in active_filters}
|
||||
|
||||
async def get_active_status(filter_id):
|
||||
function_module = await get_function_module(request, filter_id)
|
||||
|
|
@ -51,6 +42,18 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
|
|||
active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active}
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
|
||||
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
|
||||
|
||||
async def get_priority(function_id):
|
||||
try:
|
||||
function_module = await get_function_module(request, function_id)
|
||||
if function_module and hasattr(function_module, 'Valves'):
|
||||
valves_db = valves_by_id.get(function_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, 'priority', 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Pre-compute priorities (async functions can't be used in sort keys)
|
||||
priorities = {}
|
||||
|
|
@ -64,13 +67,17 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
|
|||
# Grant these filters the discernment to pass what serves
|
||||
# and refuse what harms, for every soul in the house.
|
||||
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
|
||||
if not ENABLE_PLUGINS:
|
||||
return form_data, {}
|
||||
|
||||
skip_files = None
|
||||
valves_by_id = None
|
||||
filter_ids = [function.id for function in filter_functions if function]
|
||||
|
||||
for function in filter_functions:
|
||||
filter = function
|
||||
filter_id = function.id
|
||||
if not filter:
|
||||
if not function:
|
||||
continue
|
||||
filter_id = function.id
|
||||
|
||||
function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream'))
|
||||
# Prepare handler function
|
||||
|
|
@ -84,7 +91,9 @@ async def process_filter_functions(request, filter_functions, filter_type, form_
|
|||
|
||||
# Apply valves to the function
|
||||
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
|
||||
valves = await Functions.get_function_valves_by_id(filter_id)
|
||||
if valves_by_id is None:
|
||||
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
|
||||
valves = valves_by_id.get(filter_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ from open_webui.env import (
|
|||
FORWARD_USER_INFO_HEADER_USER_NAME,
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}')
|
||||
|
||||
|
||||
def _mint_forward_user_jwt(user: Any) -> str:
|
||||
now = int(time.time())
|
||||
|
|
@ -59,7 +62,36 @@ def include_user_info_headers(headers: dict, user: Optional[Any] = None) -> dict
|
|||
}
|
||||
|
||||
|
||||
def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict:
|
||||
def custom_headers_require_user_groups(custom_headers: Optional[dict]) -> bool:
|
||||
if not custom_headers or not isinstance(custom_headers, dict):
|
||||
return False
|
||||
return any(
|
||||
placeholder in str(value) for value in custom_headers.values() for placeholder in USER_GROUPS_PLACEHOLDERS
|
||||
)
|
||||
|
||||
|
||||
async def get_user_groups_for_custom_headers(
|
||||
custom_headers: Optional[dict], user: Optional[Any] = None
|
||||
) -> Optional[list]:
|
||||
"""Fetch the user's groups only when a header value actually references a groups placeholder."""
|
||||
if user is None or not custom_headers_require_user_groups(custom_headers):
|
||||
return None
|
||||
|
||||
try:
|
||||
return await Groups.get_groups_by_member_id(user.id)
|
||||
except Exception:
|
||||
log.exception('Failed to resolve user groups for custom headers')
|
||||
return None
|
||||
|
||||
|
||||
async def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict:
|
||||
user_groups = await get_user_groups_for_custom_headers(custom_headers, user)
|
||||
return parse_custom_headers(custom_headers, user, metadata, request=request, user_groups=user_groups)
|
||||
|
||||
|
||||
def parse_custom_headers(
|
||||
custom_headers: dict, user=None, metadata: dict = None, request=None, user_groups: Optional[list] = None
|
||||
) -> dict:
|
||||
if not custom_headers or not isinstance(custom_headers, dict):
|
||||
return {}
|
||||
|
||||
|
|
@ -93,6 +125,8 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, r
|
|||
'{{USER_NAME}}': (user.name.strip() if user else '') or '',
|
||||
'{{USER_EMAIL}}': (user.email.strip() if user else '') or '',
|
||||
'{{USER_ROLE}}': (user.role if user else '') or '',
|
||||
'{{USER_GROUPS}}': ','.join(group.name.strip() for group in user_groups) if user_groups else '',
|
||||
'{{USER_GROUP_IDS}}': ','.join(group.id for group in user_groups) if user_groups else '',
|
||||
'{{USER_AGENT}}': user_agent,
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@ import mimeparse
|
|||
from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SURROGATE_RE = re.compile('[\ud800-\udfff]')
|
||||
|
||||
|
||||
def deep_update(d, u):
|
||||
|
|
@ -28,18 +29,26 @@ def deep_update(d, u):
|
|||
return d
|
||||
|
||||
|
||||
def _strip_filter_entry(entry):
|
||||
# Compose list-form env syntax passes surrounding quotes through verbatim
|
||||
return (entry or '').strip().strip('"\'').strip()
|
||||
|
||||
|
||||
def get_allow_block_lists(filter_list):
|
||||
allow_list = []
|
||||
block_list = []
|
||||
|
||||
if filter_list:
|
||||
for d in filter_list:
|
||||
if d.startswith('!'):
|
||||
# Domains starting with "!" → blocked
|
||||
block_list.append(d[1:].strip())
|
||||
else:
|
||||
# Domains starting without "!" → allowed
|
||||
allow_list.append(d.strip())
|
||||
for raw_entry in filter_list or []:
|
||||
entry = _strip_filter_entry(raw_entry)
|
||||
is_blocked = entry.startswith('!')
|
||||
if is_blocked:
|
||||
entry = _strip_filter_entry(entry[1:])
|
||||
if not entry:
|
||||
continue
|
||||
if is_blocked:
|
||||
block_list.append(entry)
|
||||
else:
|
||||
allow_list.append(entry)
|
||||
|
||||
return allow_list, block_list
|
||||
|
||||
|
|
@ -159,13 +168,38 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None:
|
|||
|
||||
|
||||
def get_content_from_message(message: dict) -> str | None:
|
||||
if isinstance(message.get('content'), list):
|
||||
for item in message['content']:
|
||||
if item['type'] == 'text':
|
||||
return item['text']
|
||||
else:
|
||||
return message.get('content')
|
||||
return None
|
||||
content = message.get('content')
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get('type') == 'text':
|
||||
return item.get('text')
|
||||
elif content:
|
||||
return content
|
||||
|
||||
output_text = get_output_text(message.get('output'))
|
||||
return output_text or (content if isinstance(content, str) else None)
|
||||
|
||||
|
||||
def get_output_text(output: list | None) -> str:
|
||||
if not isinstance(output, list):
|
||||
return ''
|
||||
|
||||
texts = []
|
||||
for item in output:
|
||||
if not isinstance(item, dict) or item.get('type') != 'message':
|
||||
continue
|
||||
|
||||
parts = item.get('content') or []
|
||||
if not isinstance(parts, list):
|
||||
continue
|
||||
|
||||
text = ''.join(
|
||||
str(part.get('text')) for part in parts if isinstance(part, dict) and part.get('text') is not None
|
||||
)
|
||||
if text.strip():
|
||||
texts.append(text)
|
||||
|
||||
return '\n'.join(texts)
|
||||
|
||||
|
||||
def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
|
||||
|
|
@ -212,7 +246,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
|
|||
|
||||
# All tool_calls were orphans — keep the message only if it
|
||||
# carries meaningful text or reasoning content.
|
||||
content = message.get('content', '')
|
||||
content = get_content_from_message(message) or ''
|
||||
has_meaningful_content = content.strip() if isinstance(content, str) else content
|
||||
if has_meaningful_content or message.get('reasoning_content'):
|
||||
reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'})
|
||||
|
|
@ -224,6 +258,7 @@ def convert_output_to_messages(
|
|||
output: list,
|
||||
raw: bool = False,
|
||||
reasoning_format: str | None = None,
|
||||
flatten_tool_images: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
|
||||
|
|
@ -242,6 +277,8 @@ def convert_output_to_messages(
|
|||
(for Ollama, which expects reasoning as tagged content).
|
||||
- ``'reasoning_content'``: set as ``reasoning_content`` top-level field
|
||||
(for llama.cpp, which routes it via the chat template).
|
||||
flatten_tool_images: Move tool output images into a following user
|
||||
message for Chat Completions providers.
|
||||
"""
|
||||
if not output or not isinstance(output, list):
|
||||
return []
|
||||
|
|
@ -251,6 +288,10 @@ def convert_output_to_messages(
|
|||
pending_content = []
|
||||
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
|
||||
pending_reasoning_details = []
|
||||
pending_tool_image_urls = []
|
||||
function_call_ids = {
|
||||
item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id')
|
||||
}
|
||||
|
||||
def flush_pending():
|
||||
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
|
||||
|
|
@ -275,8 +316,29 @@ def convert_output_to_messages(
|
|||
pending_reasoning = []
|
||||
pending_reasoning_details = []
|
||||
|
||||
def flush_tool_images():
|
||||
nonlocal pending_tool_image_urls
|
||||
if not pending_tool_image_urls:
|
||||
return
|
||||
|
||||
messages.append(
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Here are the images from the tool results above. Please analyze them.',
|
||||
},
|
||||
*[{'type': 'image_url', 'image_url': {'url': url}} for url in pending_tool_image_urls],
|
||||
],
|
||||
}
|
||||
)
|
||||
pending_tool_image_urls = []
|
||||
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
if item_type != 'function_call_output':
|
||||
flush_tool_images()
|
||||
|
||||
if item_type == 'message':
|
||||
# Extract text from output_text content parts
|
||||
|
|
@ -322,8 +384,17 @@ def convert_output_to_messages(
|
|||
if url:
|
||||
image_urls.append(url)
|
||||
|
||||
if image_urls:
|
||||
# Multimodal tool content with image(s)
|
||||
if flatten_tool_images:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'tool',
|
||||
'tool_call_id': item.get('call_id', ''),
|
||||
'content': content,
|
||||
}
|
||||
)
|
||||
if item.get('call_id') in function_call_ids:
|
||||
pending_tool_image_urls.extend(image_urls)
|
||||
elif image_urls:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'tool',
|
||||
|
|
@ -395,6 +466,7 @@ def convert_output_to_messages(
|
|||
pass
|
||||
|
||||
# Flush remaining content/tool_calls
|
||||
flush_tool_images()
|
||||
flush_pending()
|
||||
|
||||
return reconcile_tool_pairs(messages)
|
||||
|
|
@ -715,18 +787,10 @@ def sanitize_text_for_db(text: str) -> str:
|
|||
"""Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
# Fast path: skip work when there are no null bytes (the common case)
|
||||
if '\x00' not in text:
|
||||
# Fast path: skip work when there are no null bytes or surrogate code points.
|
||||
if '\x00' not in text and not SURROGATE_RE.search(text):
|
||||
return text
|
||||
# Remove null bytes
|
||||
text = text.replace('\x00', '').replace('\u0000', '')
|
||||
# Remove invalid UTF-8 surrogate characters that can cause encoding errors
|
||||
# This handles cases where binary data or encoding issues introduced surrogates
|
||||
try:
|
||||
text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore')
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
pass
|
||||
return text
|
||||
return SURROGATE_RE.sub('', text.replace('\x00', ''))
|
||||
|
||||
|
||||
def _strip_null_bytes_deep(obj):
|
||||
|
|
@ -734,7 +798,10 @@ def _strip_null_bytes_deep(obj):
|
|||
if isinstance(obj, str):
|
||||
return sanitize_text_for_db(obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {k: _strip_null_bytes_deep(v) for k, v in obj.items()}
|
||||
cleaned = {}
|
||||
for k, v in obj.items():
|
||||
cleaned[sanitize_text_for_db(k) if isinstance(k, str) else k] = _strip_null_bytes_deep(v)
|
||||
return cleaned
|
||||
elif isinstance(obj, list):
|
||||
return [_strip_null_bytes_deep(v) for v in obj]
|
||||
return obj
|
||||
|
|
@ -744,19 +811,21 @@ def sanitize_data_for_db(obj):
|
|||
"""Recursively sanitize all strings in a data structure for database storage.
|
||||
|
||||
Performs a fast pre-check: serializes the structure once and scans for
|
||||
null bytes. If none are found (the overwhelmingly common case), the
|
||||
null bytes or invalid UTF-8 surrogates. If none are found, the
|
||||
original object is returned immediately, skipping the expensive
|
||||
recursive walk.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return sanitize_text_for_db(obj)
|
||||
# Fast path: check for null bytes in the serialized form.
|
||||
# Fast path: check for null bytes and surrogate code points in the serialized form.
|
||||
# json.dumps is implemented in C and much faster than a Python-level
|
||||
# recursive walk over every leaf string.
|
||||
try:
|
||||
if '\\u0000' not in json.dumps(obj, ensure_ascii=False):
|
||||
serialized = json.dumps(obj, ensure_ascii=False)
|
||||
if '\\u0000' not in serialized:
|
||||
serialized.encode('utf-8')
|
||||
return obj
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, UnicodeEncodeError):
|
||||
pass
|
||||
return _strip_null_bytes_deep(obj)
|
||||
|
||||
|
|
|
|||
4
backend/open_webui/utils/model_ids.py
Normal file
4
backend/open_webui/utils/model_ids.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
|
||||
if prefix_id and model_id.startswith(f'{prefix_id}.'):
|
||||
return model_id[len(f'{prefix_id}.') :]
|
||||
return model_id
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue