Compare commits

..

365 Commits

Author SHA1 Message Date
_00_ 0ffc04797f i18n: Update es-ES Spanish translations v0.9.6 (#25836)
i18n: Update Spanish translations v0.9.6

Update of new strings
2026-06-17 03:09:26 +02:00
G30 b2809e6293 perf(ui): hoist marked.use() to module scope to prevent extension stacking (#25837) 2026-06-17 03:09:09 +02:00
G30 beb9bf60e4 feat(memory): default user memory toggle to admin's global ENABLE_MEMORIES config (#25909) 2026-06-17 03:08:43 +02:00
G30 2f9b28a57d fix(ui): run initChatList API calls concurrently in Promise.all (#25838) 2026-06-17 03:08:02 +02:00
Classic298 d501e3d6b5 Update milvus_multitenancy.py (#25857) 2026-06-17 03:07:27 +02:00
G30 819ad1d904 fix(chat): convert recursive findClosingDelimiter to iterative loop to prevent stack overflow (#25845) 2026-06-17 03:07:12 +02:00
G30 9fe3a00dba perf(chat): cache KaTeX rendered HTML in reactive statement to avoid redundant renders (#25847) 2026-06-17 03:06:56 +02:00
G30 4584adf900 feat(ui): add 'Unshare All Shared Chats' button to Shared Chats modal (#25848) 2026-06-17 03:06:41 +02:00
G30 dfdb76cc46 fix(ui): prevent Code Editor drawer from collapsing when content is empty (#25855) 2026-06-17 03:06:15 +02:00
Classic298 17df026492 Confer object-derived file write only for files the object owner owns (#26032)
has_access_to_file() derives file access from the objects a file is attached to
(knowledge bases, workspace models). Those branches returned True for any access_type
whenever the user held that permission on the object, write/delete included. Since a
user can create their own KB or model and attach any file they can merely READ (KB
attach and the model meta.knowledge validator both gate on read access only), a user
with read access to a victim file could launder it into write/delete: attach it to an
object they own, then rename, overwrite or delete it via the write-gated file routes
(POST /files/{id}/rename, /data/content/update, DELETE /files/{id}). This is the
residual of GHSA-vjqm-6gcc-62cr (CVE-2026-54012) left open by the read-only attach
validator (CWE-863).

An object now confers write/delete on a file only when the object's owner owns that
file, so delegation originates from the file's own owner. Read is unchanged (RAG and
shared-object reads still work), and legitimate delegation is preserved: a write grant
on an object whose owner owns the attached file still confers write. Applied to all
three object branches: knowledge base, file home collection, and workspace model.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:05:57 +02:00
Classic298 e038bab66d Authorize POST /api/v1/images/edit (enforce ENABLE_IMAGE_EDIT + image-gen permission) (#26009)
The direct image-edit route was the only image-edit surface with no authorization: it ran
on get_verified_user alone, while POST /generations enforces ENABLE_IMAGE_GENERATION +
features.image_generation and the built-in edit_image tool enforces ENABLE_IMAGE_EDIT +
features.image_generation. A verified non-admin user could therefore reach the configured
image-edit provider (spending IMAGES_EDIT_OPENAI_API_KEY) even when the administrator had
globally disabled image editing (ENABLE_IMAGE_EDIT=False) or denied the user image
generation.

Split the route from the shared impl (mirroring generate_images/image_generations): the new
/edit wrapper enforces ENABLE_IMAGE_EDIT and the per-user image-generation permission, then
delegates to image_edits(). The internal callers (the edit_image tool and the chat
middleware) already gate themselves and call image_edits() directly, so they are unaffected.

Co-authored-by: jagstack <52110932+jagstack@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:05:41 +02:00
Classic298 c39be0e2d6 Update milvus.py (#25858) 2026-06-17 03:05:27 +02:00
Taylor Wilsdon 6cd0ba0b6b implement date picker and additional columns for analytics (#25922)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-17 03:03:18 +02:00
Timothy Jaeryang Baek e473ab1231 refac 2026-06-17 03:01:11 +02:00
Timothy Jaeryang Baek 4dbb2f94a6 refac 2026-06-17 02:58:11 +02:00
G30 dee07d8a30 feat(ui): show total archived chat count in ChatsModal title (#25872) 2026-06-17 02:57:27 +02:00
Algorithm5838 e031fadc35 perf: skip highlight.js auto-detection in read-only code blocks (#25824) 2026-06-17 02:57:02 +02:00
G30 b4aef82401 feat(ui): show total file count in FilesModal title (#25873) 2026-06-17 02:55:13 +02:00
Timothy Jaeryang Baek 5cdcdbaeec refac 2026-06-17 02:52:35 +02:00
Timothy Jaeryang Baek e8d55c0a8b refac 2026-06-17 02:36:20 +02:00
G30 57a5e43696 feat(permissions): add per-group features.user_webhooks permission (#25923) 2026-06-17 00:47:44 +02:00
Timothy Jaeryang Baek 7b29834d42 refac 2026-06-17 00:43:59 +02:00
G30 2b9b956dad fix(notes): sync pinned note state in sidebar and editor menu (#25918) 2026-06-17 00:42:20 +02:00
G30 25090dbf17 fix(chat): prevent false-positive unread indicators from title generation, pin/archive/folder actions, and new chat creation (#25912) 2026-06-17 00:41:44 +02:00
G30 bb2663aad1 fix(ui): gate all chat export formats by chat.export permission (#25914) 2026-06-17 00:41:18 +02:00
G30 3571db34e2 fix(ui): enforce allow_users permission in share chat modal (#25915) 2026-06-17 00:40:57 +02:00
Timothy Jaeryang Baek 7d1f941580 refac 2026-06-17 00:40:04 +02:00
Timothy Jaeryang Baek ed4cb358a0 refac 2026-06-17 00:39:11 +02:00
Timothy Jaeryang Baek caedcbae49 refac 2026-06-17 00:36:34 +02:00
Timothy Jaeryang Baek 9ccda6715c refac 2026-06-17 00:35:08 +02:00
Timothy Jaeryang Baek edf3ae9209 refac 2026-06-17 00:33:30 +02:00
G30 0726db7217 fix(analytics): remove duplicate onMount causing double API requests on dashboard load (#25935) 2026-06-17 00:29:07 +02:00
G30 9c6c375dfe fix(images): add null guard for config in onMount to prevent crash on API failure (#25933) 2026-06-17 00:28:42 +02:00
G30 3e3c5b6d78 fix(permissions): add admin bypass to memories permission checks (#25924) 2026-06-17 00:28:17 +02:00
G30 2bc91e8f52 fix(ui): add missing skills permission to workspace menu visibility check (#25925) 2026-06-17 00:27:25 +02:00
G30 e5ed45fb20 fix(ui): handle null chat in folder drop handler for cross-account drag (#25928) 2026-06-17 00:26:35 +02:00
Timothy Jaeryang Baek 232421f40b refac 2026-06-17 00:25:35 +02:00
G30 6b2d962cd6 feat(permissions): add reset to defaults button in permissions modals (#25931)
Adds a 'Reset to Defaults' button to both the Edit Default Permissions
modal and the Edit User Group permissions modal.

- Default permissions modal: resets to stock/env-var configuration
- User group modal: resets to current global default permissions

Backend: new GET /api/v1/users/default/permissions/defaults endpoint
that returns DEFAULT_USER_PERMISSIONS (env-var-based initial defaults).
2026-06-17 00:25:20 +02:00
Timothy Jaeryang Baek 7ee75a0c04 refac 2026-06-17 00:17:48 +02:00
Timothy Jaeryang Baek fc9c2ea191 refac 2026-06-17 00:17:14 +02:00
G30 6f2e97aa58 fix(evaluations): prevent duplicate leaderboard API requests on navigation (#25934) 2026-06-17 00:16:27 +02:00
Classic298 5f3a628a8d Encode terminal ws session_id to block upstream user_id query injection (#26042)
ws_terminal() interpolated the path parameter session_id directly into the upstream
terminal WebSocket URL and then appended ?user_id=<caller>, with no encoding or
validation (the HTTP sibling proxy_terminal runs _sanitize_proxy_path; this path ran
nothing). An encoded '?'/'&' smuggled through session_id survives Open WebUI's single
decode and is re-decoded by the upstream, injecting an attacker-chosen user_id ahead
of the appended one. Query parsing binds the first occurrence, so the orchestrator
resolves the spoofed user's terminal scope, letting a normal authenticated user
present another user's identity to the upstream (CWE-116/863).

Encode session_id as an opaque path segment with urllib.parse.quote(session_id,
safe=''). This neutralises '?'/'#'/'&' at any decode depth (the upstream's single
decode reverses only the quote, leaving the original delimiters inert as path
content), while legitimate UUID session ids pass through unchanged. The appended
user_id is then the only query parameter the upstream binds.

The separate concern that the forwarded identity is a bearer claim with no integrity
binding spans Open WebUI and the upstream terminal server and is not addressed here.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:15:39 +02:00
Timothy Jaeryang Baek 8b35ce924b refac 2026-06-17 00:15:04 +02:00
G30 966b3fdb57 fix(ui): deduplicate layout-level API requests (#25942) 2026-06-17 00:09:50 +02:00
G30 ee3a49a88d fix(ui): deduplicate store fetches with null-guards and mutex (#25943) 2026-06-17 00:07:11 +02:00
Classic298 22f2fe1ffb Require auth on ydoc:awareness:update and ydoc:document:leave handlers (CWE-306) (#25946)
These were the only two ydoc Socket.IO handlers missing the SESSION_POOL guard that
every sibling (join/state/update) enforces. With always_connect=True admitting
unauthenticated sockets, a client that knew a note's document_id could broadcast
spoofed awareness (cursors/presence) and fake ydoc:user:left events into a live
editing room, attributed to any client-supplied user_id.

Both handlers now require an authenticated SESSION_POOL session; the awareness handler
additionally requires prior ydoc:document:join (room membership); and the broadcast
user_id is fixed to the authenticated identity instead of the client-supplied value,
removing the impersonation. Document content was never reachable (ydoc:document:update
already enforces write permission).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:06:13 +02:00
Timothy Jaeryang Baek 993e749121 refac 2026-06-17 00:05:45 +02:00
Classic298 4c06b392da Don't let SCIM's active flag demote an admin (defense-in-depth) (#25948)
The SCIM update_user (PUT) and patch_user (PATCH) handlers mapped the SCIM active field
unconditionally onto the role column (role = 'user' if active else 'pending'), so a routine
IdP sync or a misconfigured IdP that marked a locally-provisioned admin inactive would
silently strip that admin's role and could lock an instance out of its own administration.

Gate both active->role assignments on user.role != 'admin' so SCIM provisioning can
activate/deactivate ordinary users but never demotes an existing admin; admin role changes
continue to go through the dedicated admin endpoints. SCIM already cannot promote to admin
(active only maps to user/pending), so this is symmetric.

Credit to @HOHK0923 for surfacing the admin-demotion footgun.

Co-authored-by: HOHK0923 <118590749+HOHK0923@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:59:55 +02:00
Timothy Jaeryang Baek fbcdcf146b refac 2026-06-16 23:59:24 +02:00
Jamie Lin ec86ce5cf7 fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow (#25958)
* fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow

The Dynamic Client Registration flow seeded the registration request
`scope` from the Authorization Server's `scopes_supported` (RFC 8414),
which is a full catalog of every scope the AS can grant across all
resources. Per RFC 9728 and the MCP Scope Selection Strategy, the
resource-specific Protected Resource Metadata `scopes_supported` is the
correct, least-privilege source.

The PRM is already fetched in this function; this change prefers its
`scopes_supported` and keeps the AS `scopes_supported` only as a
fallback when the PRM advertises none. Mirrors the static-credentials
fix in #24690.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(oauth): trim DCR scope comment per review feedback

Shortens the inline comment per review feedback on #25958.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:53:38 +02:00
Classic298 087878ce84 Match WEB_FETCH_FILTER_LIST on hostnames with label boundaries, not URL suffix (CWE-693) (#25949)
is_string_allowed does endswith() matching and was called with the full URL
(retrieval/web/utils.py) against WEB_FETCH_FILTER_LIST, so a blocklisted host with any
path (https://blocked.example/x) ended with /x, not the host, and slipped through; the
allowlist direction false-rejected legitimate URLs and admitted attacker URLs ending in
an allowed string. The same endswith caused label confusion at the hostname call site
(retrieval/web/main.py): corp.com matched evilcorp.com, 10.0.0.1 matched 110.0.0.1.

Add is_host_allowed(host, ...) matching on DNS label boundaries (host == pattern or
host.endswith('.' + pattern)), called with the parsed hostname at both web-fetch call
sites. is_string_allowed is left unchanged for the unrelated function-name filters
(utils/middleware.py, utils/tools.py).

The separate is_global guard (validate_url / _ssrf_safe_new_conn, active when
ENABLE_RAG_LOCAL_WEB_FETCH is off) already blocks RFC1918/loopback/link-local, so this
restores the admin's intended blocking of specific public hosts.

Co-authored-by: addcontent <59762500+addcontent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:53:08 +02:00
Timothy Jaeryang Baek 4f69c33de0 refac 2026-06-16 23:52:33 +02:00
Timothy Jaeryang Baek c205d3a353 refac 2026-06-16 23:48:01 +02:00
Timothy Jaeryang Baek 4210cae68e refac 2026-06-16 23:41:17 +02:00
Classic298 de8ea08f5c Route user-supplied image-URL fetches through an SSRF-safe session (DNS rebinding, CWE-918) (#25960)
The connection-layer DNS-rebinding guard (_SSRFSafeResolver / _SSRFSafeAdapter, PR #24759) was
mounted only on SafeWebBaseLoader. Two user-reachable image fetches validate the URL then fetch
it through the shared get_session() pool with the default resolver, so a TTL-0 rebinding answer
that passed validate_url reaches an internal address at connect:

- get_image_base64_from_url (utils/files.py): user image_url on every chat completion.
- load_url_image (routers/images.py, POST /api/v1/images/edit): user-supplied image field.

Add get_ssrf_safe_session() (a one-off aiohttp session mounting _SSRFSafeResolver) and use it
for both fetches, so the connect-time IP is re-validated and a rebound loopback / RFC1918 /
metadata address is rejected. The shared pool is left untouched for the admin-configured
image-generation callers, which legitimately reach internal hosts.

Co-authored-by: dhyabi2 <32069256+dhyabi2@users.noreply.github.com>
Co-authored-by: geo-chen <2404584+geo-chen@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:36:53 +02:00
Algorithm5838 d07e4154fe fix: prevent model edit loss from access auto-save (#26004) 2026-06-16 23:36:36 +02:00
Timothy Jaeryang Baek bb1419328b refac 2026-06-16 23:30:24 +02:00
Timothy Jaeryang Baek 40c09167cd refac 2026-06-16 23:24:27 +02:00
Timothy Jaeryang Baek 56ae99e96a refac 2026-06-16 23:02:20 +02:00
G30 1eecbc1ac2 fix(ui): collapse Code Execution settings when toggle is disabled (#25970) 2026-06-16 23:01:22 +02:00
Timothy Jaeryang Baek 78a5015846 refac 2026-06-16 23:00:31 +02:00
G30 b93d560788 feat(ui): pre-select last model when adding a new model slot (#25974) 2026-06-16 22:56:56 +02:00
Classic298 b7626f05fb Authorize KB write access before auto-linking an uploaded file (CWE-862/863) (#26001)
process_uploaded_file auto-links an uploaded file to the knowledge base named in
client-supplied metadata.knowledge_id, but it called Knowledges.add_file_to_knowledge_by_id
directly with no authorization, while the dedicated POST /knowledge/{id}/file/add endpoint
gates the same operation on owner / admin / write grant. A read-only collaborator (or any
verified user who knows the KB id) could therefore attach arbitrary files to a knowledge base
they cannot write: the KnowledgeFile membership row was committed, and the later vector-index
write check failed closed but left the row in place.

Gate the auto-link on the same owner/admin/write check before inserting the membership row;
otherwise skip the link and log. The file still uploads as the user's own file, it just is
not linked to a KB they cannot write.

Co-authored-by: jagstack <52110932+jagstack@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:56:24 +02:00
Martin Gronek 1fc0e3ade7 fix: correct German date format token for dayjs in de-DE translation (#25985)
TT and JJJJ are German placeholders but not valid dayjs format tokens.
Replaced with DD and YYYY which dayjs understands correctly.
2026-06-16 22:55:23 +02:00
G30 84e4110538 fix(auth): enforce chat.tts permission on OpenAI /audio/speech proxy endpoint (#25993) 2026-06-16 22:54:35 +02:00
Timothy Jaeryang Baek 19a176fd36 refac 2026-06-16 22:53:57 +02:00
G30 bb66d435b7 fix(auth): enforce features.api_keys permission on GET and DELETE /api_key endpoints (#25992) 2026-06-16 22:48:44 +02:00
Classic298 dc4924b66e Enforce per-model access on arena fallback before bypass_filter dispatch (#26046)
generate_chat_completion() checks model access on line 200 only when bypass_filter is
False. When an arena model reaches this function without a pre-resolved selected_model_id,
which is the task and background path (the /api/v1/tasks/* endpoints call
generate_chat_completion directly rather than through process_chat_payload), the fallback
resolves the arena to an underlying model and recurses with bypass_filter=True, so the
resolved model's access check is skipped. An authenticated user with access to an arena
could therefore reach a model they are denied directly, and for the default or exclude
arena, whose candidate pool is every non-arena model, any model on the instance (CWE-862).
The normal chat path resolves the arena in process_chat_payload before this function, so its
resolved model is checked on line 200; the task path was not, which is the inconsistency.

Enforce check_model_access() on the resolved model in the fallback, before the
bypass_filter=True recursion, mirroring the normal-path check. Admins and already-bypassed
recursive calls are unaffected, and legitimate arena use of accessible models is unchanged.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:45:42 +02:00
Classic298 920b655f46 Gate scheduled automations and fail closed on per-model access for non-user roles (#26047)
Two lifecycle/authorization gaps let a deactivated (pending) account keep acting:

1. The background automation scheduler (execute_automation) rehydrated the owner by ID and
   dispatched the chat pipeline without re-checking the owner. A user later set to pending,
   or one whose features.automations permission was revoked, kept running scheduled
   automations on the operator's provider credentials, even though the HTTP create/update/run
   routes already gate on get_verified_user + features.automations. Re-gate the rehydrated
   owner before dispatch: require role user/admin and, for non-admins, the features.automations
   permission; otherwise record an error and skip the run.

2. check_model_access enforced per-model ACLs only for exactly role == 'user', so any other
   non-admin role (a pending principal) fell through and was granted access. Enforce for every
   non-admin role (admins still bypass), so the check fails closed (CWE-862, CWE-863).

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:45:27 +02:00
Classic298 05098d25a5 Fail closed when the proxy/redirect path decode cap is exceeded (#26050)
The decode-until-stable loops in _sanitize_proxy_path (terminals.py, cap 8) and
_safe_static_redirect_path (models.py, cap 2) proceed with whatever remains after the
cap instead of rejecting it. A path encoded more times than the cap therefore exits the
loop still percent-encoded, passes the literal '..' / prefix checks (the dots are still
%2E, not '..'), and is forwarded to the upstream terminal server, or emitted as a
redirect Location, which then decodes it once more and resolves the traversal. This is
the residual of the decode-until-stable hardening added for CVE-2026-54017: the cap is a
fixed depth, not a true stability guarantee.

Reject when the value is still not stable after the cap (unquote(x) != x), so anything
encoded more deeply than the cap fails closed rather than being forwarded. Legitimate
paths stabilize within a pass or two and are unaffected.

Co-authored-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:44:11 +02:00
Timothy Jaeryang Baek 3266a8c9eb refac 2026-06-16 22:43:40 +02:00
Andrew Baek ecdb6f353a Merge pull request #26060 from andrewbbaek/dev
feat: add auth-login-card ID for custom CSS targeting
2026-06-16 22:35:25 +02:00
Timothy Jaeryang Baek 084d040e22 refac 2026-06-16 00:05:14 +02:00
Timothy Jaeryang Baek 76854d1424 refac 2026-06-15 23:53:52 +02:00
Timothy Jaeryang Baek 45fcf272ef refac 2026-06-15 23:42:29 +02:00
Timothy Jaeryang Baek c783fd30f2 refac 2026-06-15 23:37:38 +02:00
Timothy Jaeryang Baek d65ac445a4 refac 2026-06-15 23:34:24 +02:00
Timothy Jaeryang Baek 38920c0ed1 refac 2026-06-15 23:32:06 +02:00
Timothy Jaeryang Baek 5019af79a0 refac 2026-06-15 23:31:59 +02:00
Timothy Jaeryang Baek f85cb27ef8 refac 2026-06-13 02:13:51 +01:00
Timothy Jaeryang Baek 4602abe5c6 refac 2026-06-13 02:13:39 +01:00
Timothy Jaeryang Baek b1d40f3409 refac 2026-06-05 16:58:16 -04:00
Timothy Jaeryang Baek 1a97751e37 refac 2026-06-01 19:09:44 -07:00
Timothy Jaeryang Baek dc4dab7a07 refac 2026-06-01 19:01:14 -07:00
Timothy Jaeryang Baek 4be25c4fdd refac 2026-06-01 15:01:01 -07:00
Timothy Jaeryang Baek de2360ac07 refac 2026-06-01 14:38:54 -07:00
Timothy Jaeryang Baek 8862210139 refac 2026-06-01 14:38:49 -07:00
Timothy Jaeryang Baek 5062f9958f refac 2026-06-01 14:35:45 -07:00
Timothy Jaeryang Baek eb629866fe chore: format 2026-06-01 14:33:43 -07:00
Timothy Jaeryang Baek 54149903f0 refac 2026-06-01 14:23:27 -07:00
Classic298 02b2a391e9 fix: block private-IP webhook URLs to close SSRF on caller-controlled URL (#24587)
* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL

post_webhook(url, ...) in utils/webhook.py forwards the URL straight to
aiohttp.ClientSession.post with no SSRF gate. The URL is caller-controlled
on two surfaces:

- User notification settings under ENABLE_USER_WEBHOOKS=true — any
  authenticated user can set the URL their notifications POST to.
- Automation notification triggers (calendar alerts, etc.).

Without a gate, the URL can target cloud metadata (169.254.169.254 /
fd00:ec2::254), localhost-bound services, RFC1918 internal hosts, or any
other private address reachable from the server process. Blind SSRF — no
response body returned to the caller — but enough to enumerate internal
services via response timing / status codes, and on cloud deployments
enough to issue requests against IMDSv1 if available.

Call validate_url() at the top of post_webhook. The function blocks
private/reserved IPs when ENABLE_RAG_LOCAL_WEB_FETCH is False (the
default), is the project's chosen SSRF gate, and is already applied to
the equivalent fetch surfaces (retrieval, image-load, OAuth profile
picture). Operators who legitimately need to webhook to private IPs
(internal monitoring, self-hosted Slack alternatives, etc.) can set
ENABLE_RAG_LOCAL_WEB_FETCH=True — same opt-out as the other gated
surfaces.

Scope intentionally limited to webhooks. The OAuth discovery and
external reranker paths cwanglab also flagged are admin-configured with
intentional private-IP defaults (reranker defaults to
http://localhost:8080/v1/rerank) and are out of scope per Rule 9 — the
admin owns the URL choice and the operator opt-out exists for them too.

Reported by cwanglab in GHSA-5x9f-85cg-w3hf (cluster canonical with six
closed siblings: g36v-23gj-j69x, 6j8f-h58v-xgmw, xpwv-52pm-p8hj,
v9gp-hv2c-9qv8, fw7w-jrw7-p3v9, x7xq-74rg-m8mf).

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* fix: also pass allow_redirects=False on webhook post_webhook session.post

Companion to the previous commit. validate_url() only validates the
initial URL; aiohttp's default allow_redirects=True would still follow
a 302 to a private-IP target. Same redirect-bypass class as the rh5x
cluster's five call sites, sixth call site to receive the same gate.

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

---------

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>
2026-06-01 14:15:51 -07:00
hungryBird c73d9da773 i18n(fr-fr): update frensh translations (#24614)
Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-01 14:15:38 -07:00
Classic298 eb38389636 fix: delete Qdrant points by ID so memory deletions don't orphan vectors (#25495)
The Qdrant backends implemented delete(ids=...) as a payload filter on
metadata.id, but points are stored with the item id as the Qdrant point id
(see _create_points), and not every point carries an id in its payload.
Memory points store only {created_at} in metadata (KB metadata embeddings
likewise), so deleting a single memory matched nothing and left an orphaned
vector that kept being injected into RAG context.

Delete by point id instead: PointIdsList for the standard backend, and a
tenant-scoped HasIdCondition for multitenancy (point ids are unique, so tenant
isolation is preserved). Filter-based deletion is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:13:46 -07:00
Timothy Jaeryang Baek eebbc48f80 refac
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2026-06-01 14:13:28 -07:00
Timothy Jaeryang Baek cff51f05f5 chore: format 2026-06-01 14:10:40 -07:00
Timothy Jaeryang Baek a4735e46b9 refac
Co-Authored-By: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>
2026-06-01 14:09:54 -07:00
Timothy Jaeryang Baek 6c8dfd8175 refac 2026-06-01 14:08:34 -07:00
Classic298 caaa99c7d3 Update CHANGELOG.md (#25491)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-06-01 14:07:20 -07:00
Timothy Jaeryang Baek 6fce92aa12 chore: format 2026-06-01 13:56:55 -07:00
Timothy Jaeryang Baek 1f49ebf431 chore 2026-06-01 13:56:06 -07:00
Timothy Jaeryang Baek bfe5523c52 chore: bump 2026-06-01 13:54:10 -07:00
Timothy Jaeryang Baek 77c8c54b1e refac 2026-06-01 13:53:19 -07:00
Justin Williams 478bc9e3f1 fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690)
The static credentials OAuth flow currently sets scope=None, relying on
the OAuth provider's default scopes. This breaks providers like GitHub
that default to minimal/public-only access when no scope is requested.

This change reads scopes_supported from the Protected Resource Metadata
document (RFC 9728) and uses them in the authorization request. Unlike
the Authorization Server's scopes_supported (a full catalog of every
scope the AS can grant), the PRM scopes_supported represents what the
specific resource requires — making it safe to request without breaking
providers like Entra ID that reject broad scope requests.

Fixes the regression introduced in 349ea4ea where all scope handling was
removed from the static flow.
2026-06-01 13:52:18 -07:00
Timothy Jaeryang Baek 675e9bee5a refac 2026-06-01 13:51:03 -07:00
Jacob Leksan 80da840ae5 refactor: move background tasks handler call to ensure consistent execution in chat response handlers (#24717) 2026-06-01 13:50:15 -07:00
Chane Lu 974b13839e fix: polyfill readable stream async iteration for Safari PDF extraction (#25473) 2026-06-01 13:47:57 -07:00
Classic298 886c132a10 Update CHANGELOG.md (#25453)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-06-01 13:46:17 -07:00
Timothy Jaeryang Baek c7de057a4a refac 2026-06-01 13:45:23 -07:00
Timothy Jaeryang Baek 750604a11d refac 2026-06-01 13:43:05 -07:00
James Liounis 69c88e163d feat(retrieval): add Perplexity attribution header (#24833)
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
2026-06-01 13:40:52 -07:00
Timothy Jaeryang Baek c8eb8edca4 refac 2026-06-01 13:38:40 -07:00
Classic298 33e4e0dcc4 fix: gate chat_completion channel: branch on channel access + message scoping (#24725)
* fix: gate chat_completion channel: branch on channel access + message scoping

When chat_id starts with 'channel:' the chat-completion handler skips
the chat ownership / storage block below it. Nothing replaced that
gate. The downstream channel emitter in socket/main.py:_make_channel_
emitter writes to Messages.update_message_by_id using a caller-supplied
message_id pulled from form_data['id'], with no membership check, no
write-access check, and no validation that the message_id belongs to
the channel.

Net effect: any authenticated user could submit
chat_id='channel:<any-channel-uuid>' + id='<any-message-uuid>' and
overwrite that message with attacker-controlled LLM output. Cross-
channel writes worked too — private channels, DMs, channels the
caller has no access to. Original author attribution stayed intact on
the overwritten row.

Add the missing checks at the channel: branch:

1. Channel must exist (404 otherwise).
2. Non-admin caller must have write access to the channel — membership
   for group/dm channels, AccessGrants permission='write' for others.
3. The message_id (if supplied) must belong to the same channel — a
   caller with write access to channel A cannot use this path to
   overwrite a message in channel B.

Behaviour change is limited to callers who were exploiting the gap:
legitimate flows that supply a message_id under their own channel
membership continue to work unchanged.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* chore: trim verbose comment on channel: branch gate

---------

Co-authored-by: sfwani <sfwani@users.noreply.github.com>
2026-06-01 13:37:32 -07:00
Timothy Jaeryang Baek 7f7cd21018 refac 2026-06-01 13:34:50 -07:00
Craig ce4dca47cb fix: apply RAG_EMBEDDING_QUERY_PREFIX to memory search queries (#24921)
The query_memory endpoint embeds the search query without the configured
RAG_EMBEDDING_QUERY_PREFIX, while every RAG retrieval path in
retrieval/utils.py correctly passes it. Instruction-tuned embedding
models (e.g. Qwen3-Embedding) produce poor results without the prefix,
causing memory search to return semantically unrelated results.
2026-06-01 13:23:15 -07:00
Timothy Jaeryang Baek 160a6694e4 refac 2026-06-01 13:20:33 -07:00
Timothy Jaeryang Baek 4705c2d988 refac 2026-06-01 13:20:26 -07:00
Timothy Jaeryang Baek 778dba1d6b refac 2026-06-01 13:18:44 -07:00
Timothy Jaeryang Baek 27fb20c13a refac 2026-06-01 13:15:21 -07:00
Timothy Jaeryang Baek eb4eebc3ce refac 2026-06-01 13:10:19 -07:00
Timothy Jaeryang Baek d64ef1803d refac
Co-Authored-By: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
2026-06-01 13:07:49 -07:00
Timothy Jaeryang Baek 346dab3d8f refac 2026-06-01 13:03:13 -07:00
Timothy Jaeryang Baek 01810e32ad refac 2026-06-01 13:02:48 -07:00
Mr. Meowgi a9da054572 feat: add skills management to chat component (#25037)
- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.
2026-06-01 12:53:30 -07:00
Lukáš Kucharczyk 42c2393f8e Update Kagi API endpoint and request method (#25015)
Co-authored-by: russelg <russelg@users.noreply.github.com>
2026-06-01 12:48:44 -07:00
Timothy Jaeryang Baek e90a618f45 refac 2026-06-01 12:47:59 -07:00
Timothy Jaeryang Baek 4297c02b12 refac 2026-06-01 12:44:16 -07:00
Timothy Jaeryang Baek 9035601bdb refac 2026-06-01 12:41:30 -07:00
Timothy Jaeryang Baek e3ab4bd212 refac
Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
2026-06-01 12:37:34 -07:00
Timothy Jaeryang Baek fd76b51ab2 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-06-01 12:27:08 -07:00
Timothy Jaeryang Baek c0f1aa2919 refac 2026-06-01 12:24:45 -07:00
rileydes-improving 567c4aabe9 feat: add support for Valkey vector database (#24769)
* feat: add support for Valkey vector database

Signed-off-by: Riley Des <riley.desserre@improving.com>

* feat: add CLIENT SETNAME to Valkey vector store connections

Set client_name on GlideClientConfiguration for both the main client
and batch client so connections are identifiable in CLIENT LIST,
monitoring dashboards, and CloudWatch metrics.

Signed-off-by: Riley Des <riley.desserre@improving.com>

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>
2026-06-01 12:20:01 -07:00
Timothy Jaeryang Baek 8644532f5b refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-06-01 12:13:04 -07:00
Classic298 9a3eea6448 fix: bind prompt history/version ops to the authorized prompt (#25056)
The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 12:07:52 -07:00
Classic298 e623081b2b fix: handle list-shape data in Firecrawl /search response (#24712)
Firecrawl /search returns either `{"data": [...]}` (flat list — v1, and
what frost19k reported on #23966) or `{"data": {"web": [...]}}` (v2,
current production). The parser only handled the dict shape:

    data = response.get('data') or {}
    results = data.get('web') or []

On a list-shape response, `data.get('web')` raised AttributeError,
caught by the function's outer try/except, and `search_firecrawl`
silently returned []. Web search worked against v2 endpoints but is one
upstream-format-change away from failing closed again. Accept either.
2026-06-01 12:07:31 -07:00
Classic298 507b8b213c refac: mirror native FC code_interpreter authz gates onto legacy XML-tag path (#24724)
The native function-calling tool resolver in utils/tools.py applies five
gates before exposing execute_code as a builtin tool: builtin-category
enable, ENABLE_CODE_INTERPRETER global config, model capability,
features.code_interpreter request flag, and the per-user
features.code_interpreter permission.

The legacy XML-tag detection path in streaming_chat_response_handler
applied only the request-flag gate. Brings the legacy path to parity by
running the same five-gate check before activating tag detection.
Behaviour change is limited to deployments that previously relied on
the asymmetry — admins who set ENABLE_CODE_INTERPRETER=False or revoked
the per-user permission, on the legacy tool-calling mode, with the
client supplying features.code_interpreter=true. Any of those three
conditions met now correctly disables tag detection.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>
2026-06-01 12:07:15 -07:00
Timothy Jaeryang Baek c93f071700 refac 2026-06-01 11:58:16 -07:00
Classic298 76947ff926 fix: reject collection names with unsafe characters in RAG ACL (#24982)
Open WebUI's collection ACL accepted any unknown name as a
legacy/ephemeral collection. In Milvus multi-tenancy mode that name
becomes the `resource_id` and is interpolated unescaped into a SQL-like
Milvus expression — `resource_id == '<name>'` — so a name like
  x' or resource_id != '' or resource_id == 'x
turns the filter into a tautology and returns every tenant's chunks
from the shared collection.

All collection names Open WebUI generates are UUIDs, SHA-256 hex
digests, or fixed-prefix variants of those — they all fit
[A-Za-z0-9_-]. Add a strict format check in
filter_accessible_collections (utils.py) that drops any name outside
that set before any ACL or vector-store lookup, applied even on the
admin bypass path. _validate_collection_access then surfaces the dropped
name as a 403.

As defense in depth, MilvusClient now validates resource_id at every
expression-construction site and escapes single quotes / backslashes in
any other string interpolated into a filter (delete ids, metadata
filter values). Non-string filter values are typed-checked instead of
str()-formatted.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 11:48:43 -07:00
Classic298 a089842368 fix: don't revert replace/outlet content on chat save (#25485)
update_chat_by_id re-derived assistant content from `output` on every
save (serialize_output) so frontend edits to output items reflect in
content. But it ran unconditionally, so content set independently of
output — an __event_emitter__ {"type":"replace"} from an Action, or an
outlet filter footer — was reverted to the original output-derived text
on the next save. The reload reads chat.chat directly, so the change
vanished after navigating away (regression vs 0.9.2, which predates the
output mechanism).

Re-derive only when the message's `output` actually changed versus what's
stored, which still reflects genuine output edits but leaves
independently-set content intact.

Fixes #24585

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:45:05 -07:00
Classic298 558ea2a152 fix: don't block first-admin signup on stale ENABLE_SIGNUP (#24821)
Symptom
On a fresh install (zero users) the frontend shows the mandatory
"Create Admin Account" onboarding screen, but POST
/api/v1/auths/signup returns 403 ACCESS_PROHIBITED ("You do not have
permission to access this resource."). Wiping the database does not
help when the config layer is backed by Redis (the value survives in
the Redis/valkey volume), or when only the user table is cleared (the
config row survives in Postgres). The instance is then unrecoverable
through the UI.

Root cause
signup_handler() auto-sets request.app.state.config.ENABLE_SIGNUP =
False immediately after the first admin is created. That value is
persisted by the config layer (the Postgres config table, and Redis
when REDIS_URL is set). On a later zero-user database the persisted
False is read back, so ENABLE_SIGNUP resolves False even though no
users exist. The old gate was:

    if WEBUI_AUTH:
        if not ENABLE_SIGNUP or not ENABLE_LOGIN_FORM:
            if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
                403

ENABLE_INITIAL_ADMIN_SIGNUP defaults to False, so with zero users the
inner test (has_users or not ENABLE_INITIAL_ADMIN_SIGNUP) is True, and
a stale ENABLE_SIGNUP=False trips the outer test, producing a 403 on
the only UI path that can create the first admin. The frontend decides
to show onboarding purely from user_count == 0, so frontend and
backend disagree and the instance bricks.

Change
Split the gate by has_users. Subsequent signups (has_users True) are
unchanged: still gated by ENABLE_SIGNUP and ENABLE_LOGIN_FORM. The
first user (has_users False, the bootstrap admin the onboarding screen
invites) is gated only by the admin-chosen ENABLE_LOGIN_FORM (the
documented SSO-only hard-disable) unless ENABLE_INITIAL_ADMIN_SIGNUP is
set. It is no longer gated by ENABLE_SIGNUP, which in the zero-user
state is never an admin decision but the post-first-admin auto-disable
leaking across a database reset.

Why this is safe (full case analysis)
For WEBUI_AUTH the gate has 16 input combinations over (has_users,
ENABLE_SIGNUP, ENABLE_LOGIN_FORM, ENABLE_INITIAL_ADMIN_SIGNUP). Old and
new are identical in 15 of them:
  * All 8 has_users=True cases: both reduce to "403 iff not
    ENABLE_SIGNUP or not ENABLE_LOGIN_FORM". Unchanged.
  * 7 of the 8 has_users=False cases: identical.
The only changed case is has_users=False, ENABLE_SIGNUP=False,
ENABLE_LOGIN_FORM=True, ENABLE_INITIAL_ADMIN_SIGNUP=False: old
behaviour 403, new behaviour allow. The new condition is a strict
subset of the old (new-403 implies old-403), so the change never newly
blocks any request that previously succeeded; it only stops blocking
that one bootstrap state.

That state has no legitimate deployment. With the login form enabled
and zero users the onboarding form is already served, and the only
operator-configurable way to keep the first signup closed (SSO-only:
ENABLE_LOGIN_FORM=False, optionally with ENABLE_INITIAL_ADMIN_SIGNUP)
is preserved byte for byte. ENABLE_SIGNUP=False with zero users is not
an operator choice, it is the automatic post-first-admin disable, so
the old behaviour there was purely a brick with no recovery path. No
security control is weakened: ENABLE_LOGIN_FORM and
ENABLE_INITIAL_ADMIN_SIGNUP keep their exact meaning, and the
WEBUI_AUTH=False path is untouched.

This is not Redis-specific: it reproduces with Redis disabled through
the Postgres config table alone (clear the user table, keep the config
row).

Verification
Drove the real signup endpoint across a 10-case matrix on freshly
migrated databases, including the full end-to-end first-admin creation
(returns role=admin, row persisted as admin) and the preserved
SSO-only, subsequent-signup and no-auth behaviours. All pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:44:36 -07:00
Timothy Jaeryang Baek ad9f2eeb15 refac 2026-06-01 11:34:46 -07:00
G30 1010fd3142 fix(knowledge): remove premature drag-and-drop upload toast (#25484) 2026-06-01 11:09:58 -07:00
Timothy Jaeryang Baek 1bbb2b933d refac 2026-06-01 11:08:58 -07:00
Classic298 cc15a01778 fix: don't crash on startup when stdout can't encode the banner (#25482)
The startup banner uses Unicode box-drawing characters. On a stdout that
can't encode them (Windows cp1252, or redirected/headless/pythonw output)
print() raises UnicodeEncodeError and aborts startup. This blocks running
open-webui serve headless on Windows.

Guard the banner print and fall back to a plain ASCII line so startup
always proceeds regardless of the console encoding.

Fixes #24965

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:41:43 -07:00
G30 ceb1cbc009 fix(ui): guard JSON.parse(localStorage) calls with try/catch to prevent UI crashes (#25481) 2026-06-01 10:33:54 -07:00
HW bddadafa07 fix(images): pass content_type=None to r.json() to accept non-standard MIME types (#24838)
aiohttp's ClientResponse.json() validates the Content-Type header against
'application/json' by default and raises ContentTypeError for any other
value — including 'application/x-ndjson', which Ollama returns for its
OpenAI-compatible /v1/images/generations endpoint.

Pass content_type=None to skip this check while keeping all other parsing
behaviour unchanged.  The fix covers image generation (openai, gemini,
automatic1111 engines) and image editing (openai, gemini engines).
2026-06-01 10:33:24 -07:00
Timothy Jaeryang Baek 936d5f2676 refac 2026-06-01 10:26:39 -07:00
Algorithm5838 309caa82fb fix: persist outlet filter changes to message output (#24884) 2026-06-01 10:24:40 -07:00
Classic298 b0fa4384ea fix: cache path traversal via sibling-prefix bypass in serve_cache_file (#25086)
serve_cache_file gated the resolved path with file_path.startswith(os.path.abspath(CACHE_DIR)) without a trailing os.sep, so any path resolving to a sibling whose name starts with the cache-dir basename (e.g. cache_backup, cached_models) passed the prefix check. Authenticated users could read files from such siblings via /cache/../<sibling>/<file>. Appending os.sep to the prefix closes the bypass; deep traversal and absolute paths were already correctly blocked.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 10:17:02 -07:00
Timothy Jaeryang Baek 07cbc91a8e refac
Co-Authored-By: Boris Rybalkin <ribalkin@gmail.com>
2026-06-01 10:16:01 -07:00
Timothy Jaeryang Baek 0e73f7af09 refac
Co-Authored-By: bannert <58707896+bannert1337@users.noreply.github.com>
2026-06-01 10:13:50 -07:00
Classic298 02b65ea582 fix: don't hang terminal proxy when one forwarding pump exits first (#25479)
The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

Use asyncio.wait(return_when=FIRST_COMPLETED) and cancel the pending
sibling, so the proxy unwinds as soon as either direction finishes. The
pumps' bare except Exception already lets CancelledError (a BaseException)
propagate, so cancellation is clean and they need no change.

Fixes #25464

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:10:04 -07:00
Timothy Jaeryang Baek 7594823edd refac 2026-06-01 10:09:40 -07:00
Classic298 83890f18b9 feat: cap profile image data URI size to bound model/avatar bloat (#25476)
* feat: cap profile image data URI size to bound model/avatar bloat

validate_profile_image_url() validated data-URI format (MIME allowlist,
SVG rejection, scheme checks) but never its length, so a valid
data:image/...;base64,<huge> passed for both custom-model icons and user
avatars. Large inline images bloat Postgres and the Redis MODELS hash and
degrade model-list latency.

Add PROFILE_IMAGE_MAX_DATA_URI_SIZE (default 256 KiB, 0 disables) and
reject oversized data URIs in the shared validator, so both model meta
(ModelMeta.profile_image_url) and user avatars (UpdateProfileForm) are
bounded at one chokepoint. ModelMeta already clears invalid values to
None on read, so existing oversized icons stop propagating into the
MODELS hash on the next refresh.

Fixes #25468

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: default PROFILE_IMAGE_MAX_DATA_URI_SIZE to None (no cap)

Per review: opt-in rather than a 256 KiB default. Unset leaves data URIs
uncapped; the validator already skips the check on a falsy value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:57:06 -07:00
Classic298 e2502ec80f fix: clear usage interval in finally so it cannot leak on send failure (#25478)
getChatEventEmitter starts a setInterval emitting a `usage` socket event
every second; clearInterval ran only after sendMessageSocket resolved, so
any throw/reject left the interval firing for the page lifetime. Each
failed send added another orphaned interval, inflating server-side usage
accounting and growing CPU/memory over a session.

Wrap the send in try/finally so the interval is always cleared, on both
the happy path and any thrown/rejected path. The exception still
propagates unchanged.

Fixes #25465

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:56:49 -07:00
Timothy Jaeryang Baek 6f0277db52 refac 2026-06-01 09:53:04 -07:00
Timothy Jaeryang Baek 55ca719bbf refac 2026-06-01 09:48:29 -07:00
Timothy Jaeryang Baek 9e3e24e304 refac 2026-06-01 09:42:54 -07:00
Timothy Jaeryang Baek b64fd988f0 refac 2026-06-01 09:30:15 -07:00
G30 229e65b9f0 fix(ui): correct inverted high-contrast text colors for user message timestamp (#25461) 2026-06-01 09:26:18 -07:00
G30 bbd3e13094 fix(ui): use correct 'blur' event name instead of 'blur-sm' in window listeners (#25459) 2026-06-01 09:25:55 -07:00
Algorithm5838 16e7827134 fix: use db instead of undefined session in chats model (#25455) 2026-06-01 09:25:35 -07:00
Classic298 5028ba18ee chore: Update CHANGELOG.md (#24680)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-05-31 18:42:49 -07:00
Timothy Jaeryang Baek 7139797be0 refac 2026-05-31 18:42:28 -07:00
Timothy Jaeryang Baek ee47c9c833 refac 2026-05-31 18:34:37 -07:00
Classic298 0354775917 fix: decode terminal proxy path until stable to block multi-encoded traversal (#25157)
_sanitize_proxy_path decoded the proxy path once before the '..' check, so a double-encoded payload (%252e%252e) survived the check as %2e%2e and was then re-decoded into '..' by the upstream terminal server, defeating the traversal guard. Decode until stable so no encoded traversal sequence can reach the upstream. Single-encoded payloads were already rejected; this closes the double (and deeper) encoding bypass.

Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:11:19 -07:00
Timothy Jaeryang Baek d4030a8aa5 refac 2026-05-31 15:10:48 -07:00
Timothy Jaeryang Baek 1f0948bcbe refac 2026-05-31 15:06:22 -07:00
Amir Subhi 746979d012 i18n : (ms-MY) refine translation and standardise terminology (#25164) 2026-05-31 15:05:01 -07:00
G30 18b1a041dd fix(ui): enable custom parameters in user settings and admin model settings (#25200)
Add missing custom={true} prop to AdvancedParams in General.svelte and
ModelSettingsModal.svelte so the 'Add Custom Parameter' option appears
consistently across all advanced parameter surfaces.

Also forward custom_params in the General.svelte save handler so custom
parameters are persisted instead of silently dropped on save.
2026-05-31 15:02:32 -07:00
G30 1608a04fa9 fix(settings): correct presence_penalty and repeat_penalty saving wrong values (#25183)
Both presence_penalty and repeat_penalty in the saveHandler read from
params.frequency_penalty instead of their own values due to a
copy-paste error. This causes users adjusting either parameter to
silently save the frequency_penalty value instead.
2026-05-31 15:02:07 -07:00
G30 5112e0b62c fix: add null guards to channel Thread and PinnedMessagesModal components (#25209)
Thread.svelte: Add null check for messagesContainerElement in scrollToBottom()
to match the existing pattern in Channel.svelte. Prevents potential TypeError
when the DOM element is not yet bound during rapid thread switches.

PinnedMessagesModal.svelte: Move res.length check inside the if (res) block.
Previously, res.length was accessed unconditionally after a guarded block,
causing TypeError when the API call fails and the .catch() returns null.
2026-05-31 15:01:17 -07:00
Timothy Jaeryang Baek f16b5c4460 refac 2026-05-31 14:59:28 -07:00
G30 34b8844ccf fix(models): gracefully handle legacy svg profile_image_url in ModelMeta validator (#25173)
The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.

This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.

Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
  ModelMeta.check_profile_image_url. Legacy entries are cleared to None
  with a warning log instead of raising — the /model/profile/image API
  endpoint already falls back to /static/favicon.png when the value is
  empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
  /static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
  future unexpected validation failure on a single record skips that
  model with an error log rather than aborting the entire list.
2026-05-31 14:55:07 -07:00
Mateusz Hajder b52d142c8d i18n(pl-PL): add missing polish translations (#25176) 2026-05-31 14:54:23 -07:00
Classic298 4719881105 fix: move bypass_system_prompt off query parameter onto request.state (#25156)
bypass_system_prompt is an internal flag used by utils/middleware.py and utils/chat.py to skip applying the model system prompt on recursive base-model calls, but it was still declared as a positional argument on the openai/ollama chat-completion route handlers, so FastAPI bound it from the query string. Move it to request.state so external clients cannot set it, matching how bypass_filter is handled.

Drop the argument from both route signatures and read getattr(request.state, 'bypass_system_prompt', False); utils/chat.py sets request.state.bypass_system_prompt alongside bypass_filter and drops the kwarg from the two route-handler calls (the recursive self-calls keep it). Mirrors c0385f60b.

Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:53:53 -07:00
Classic298 f2650353da fix: remove hardcoded WEBUI_SECRET_KEY fallback, require key explicitly (#25218)
The 't0p-s3cr3t' default was dead code on every supported startup path:
start.sh, start_windows.bat and `open-webui serve` all set or
auto-generate WEBUI_SECRET_KEY before the backend imports env.py. It was
only ever reachable by invoking uvicorn directly, which is unsupported
and unsafe (the app would then sign tokens/cookies with a public,
hardcoded key). It also keeps getting reported as a vulnerability because
it looks dangerous, even though it is unreachable in practice.

Drop the fallback (default to '') so an unset key is caught by the
existing WEBUI_AUTH guard, and replace the vague error with a clear,
actionable message explaining that the key is a hard requirement and how
the supported start methods provide it. Exit cleanly via SystemExit
instead of raising a ValueError traceback.

WEBUI_AUTH=False keeps working unchanged (key defaults to '').

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:53:25 -07:00
Sakıp Han Dursun 76b0e65818 i18n: complete Turkish (tr-TR) translation (#25210) 2026-05-31 14:52:51 -07:00
G30 9c9e4f1288 fix(types): add missing markdown rendering settings to Settings type (#25198) 2026-05-31 14:52:35 -07:00
G30 26b1529a45 fix(ui): add voice mode mute shortcut to keyboard shortcuts modal (#25193) 2026-05-31 14:52:20 -07:00
G30 c665d4a7c6 fix(ui): prevent long usernames from overflowing Edit User modal, User Preview modal, and sidebar (#25185)
Long usernames overflow the Edit User modal, User Preview modal header,
and the sidebar user area because the flex containers lack width
constraints.

- EditUserModal: add min-w-0 to the flex-1 container so the existing
  truncate class takes effect
- UserPreviewModal: add min-w-0 and truncate to the title container,
  flex-shrink-0 to the close button so it stays visible
- Sidebar: add truncate to the username display and flex-shrink-0 to
  the avatar container to prevent it from being squeezed
2026-05-31 14:51:56 -07:00
G30 c428ad0c1a fix(ui): include reasoning_tags in user settings advanced params save handler (#25204) 2026-05-31 14:50:41 -07:00
Algorithm5838 4b33d7ebc1 fix: preserve parent_id on chat_message upsert (#25205) 2026-05-31 14:50:05 -07:00
Classic298 bf6325ff33 fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219)
renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects
via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it
neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its
output; a .md file with a click X href "javascript:..." directive (or an
HTML-label payload) therefore executes script in the app origin when previewed.
The chat path was already safe because SVGPanZoom DOMPurifies before rendering;
file preview was not.

Sanitize at the source: renderMermaidDiagram now returns DOMPurify-cleaned SVG
via a shared sanitizeSvg helper (same policy as SVGPanZoom), so every consumer
including the FilePreview innerHTML sink receives safe output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:49:43 -07:00
maco 690d6e5eb1 fix(i18n): add missing Korean plural _one keys for selected/sources/minutes (#25228) 2026-05-31 14:49:19 -07:00
Classic298 81d4ed79ae Update main.py (#25271) 2026-05-31 14:48:51 -07:00
Kylapaallikko 4923920bf1 Update fi-FI translation.json (#24963)
Added missing translations and improved existing ones.
2026-05-28 17:48:06 -05:00
Classic298 3f1c52e018 fix: gate chat-file links by caller access + repair insert_chat_files db arg (#25054)
insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.

Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.
2026-05-28 17:42:17 -05:00
Classic298 f5f4b58958 fix: harden model profile image against SVG stored XSS (#25060)
ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:41:55 -05:00
Timothy Jaeryang Baek 78b1637a03 refac 2026-05-28 17:29:01 -05:00
Timothy Jaeryang Baek 84659035f0 refac 2026-05-28 17:28:14 -05:00
Timothy Jaeryang Baek 91810f1c4e refac 2026-05-28 17:26:31 -05:00
Timothy Jaeryang Baek 591e0aafa1 refac 2026-05-28 17:24:33 -05:00
Shirasawa e61bcc8500 I18n/improve chinese translation (#25114)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-05-28 16:44:55 -05:00
G30 66126f3861 fix(auth): use request.scope["path"] to prevent CVE-2026-48710 (BadHost) (#25123)
Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.

The API key endpoint restriction check was using request.url.path to decide
whether to allow or deny access — making it bypassable via a crafted Host
header on any Starlette version prior to 1.0.1.

Fix: replace request.url.path with request.scope["path"], which reads the
raw ASGI scope path that Starlette uses for routing. This value is set by
the ASGI server from the actual request path and cannot be injected via
HTTP headers, making it safe regardless of Starlette version.

Affected code path:
  get_current_user_by_api_key() in backend/open_webui/utils/auth.py
  (only triggered when ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS is enabled)

References:
  CVE-2026-48710 / BadHost
  https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/
2026-05-28 16:41:56 -05:00
Timothy Jaeryang Baek 79bf3d28d8 refac 2026-05-28 16:33:48 -05:00
Classic298 9f20687680 fix: add knowledge_id access check in search_knowledge_files (BOLA) (#25113)
When called without attached model knowledge and given a caller-supplied knowledge_id, search_knowledge_files passed it straight to Knowledges.search_files_by_id, which does not enforce ownership on knowledge_id. An authenticated user who happened to know a target UUID could enumerate file metadata (filename, file id, KB id, KB name, updated_at) from any knowledge base, bypassing the AccessGrants permission model.

Mirror the same admin/owner/AccessGrants check the attached-KB branch already uses, matching the sibling query_knowledge_files function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:23:49 -05:00
G30 48ea043f82 fix(prompts): resolve undefined session variable in _get_access_grants and _to_prompt_model (#25129)
Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.

The NameError was silently swallowed by the broad 'except Exception'
clause in get_prompt_by_id, which returned None — causing the frontend
[id]/+page.svelte to immediately redirect back to /workspace/prompts
rather than rendering the prompt editor.

Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.
2026-05-28 16:23:25 -05:00
Timothy Jaeryang Baek a4d1b3e937 refac 2026-05-28 16:19:35 -05:00
Timothy Jaeryang Baek 5d9a09a88a refac 2026-05-25 20:15:03 +04:00
Classic298 9a347b0376 Update knowledge.py (#25053) 2026-05-25 20:13:11 +04:00
Timothy Jaeryang Baek 42783881e9 refac 2026-05-21 18:43:45 +04:00
Timothy Jaeryang Baek fb16e28d28 refac 2026-05-21 17:48:28 +04:00
Timothy Jaeryang Baek 470a074cd1 refac 2026-05-21 16:56:56 +04:00
Timothy Jaeryang Baek b94245d2ee refac 2026-05-21 16:44:36 +04:00
Timothy Jaeryang Baek 1acfbb6755 refac 2026-05-21 16:25:25 +04:00
Timothy Jaeryang Baek d8b5b9fa79 refac 2026-05-21 15:29:49 +04:00
Timothy Jaeryang Baek 260ead64da refac 2026-05-21 14:01:57 +04:00
Timothy Jaeryang Baek cac4c6da2e fix: resolve NameError for redis_sentinels in session_cleanup_lock
The variable was renamed to ws_sentinels but session_cleanup_lock
still referenced the old name, causing a startup crash.
2026-05-21 13:41:21 +04:00
Timothy Jaeryang Baek 154679200f refac: clean up Redis sentinel utilities and import grouping 2026-05-21 11:47:25 +04:00
Timothy Jaeryang Baek 1527eb6e01 refac 2026-05-20 01:46:59 +04:00
Timothy Jaeryang Baek 8f2d346e10 refac 2026-05-20 01:39:22 +04:00
Timothy Jaeryang Baek 97252fa609 refac 2026-05-20 01:34:07 +04:00
Timothy Jaeryang Baek 9835b3f1dd refac 2026-05-20 01:32:25 +04:00
Timothy Jaeryang Baek 73bdf86766 refac 2026-05-20 01:30:26 +04:00
Timothy Jaeryang Baek 60c9db1cb8 refac: kb sync 2026-05-20 01:13:52 +04:00
Timothy Jaeryang Baek d0b17f0569 refac 2026-05-20 00:37:04 +04:00
Classic298 b9911c8bc6 refactor: remove unused GET /prompts/command/{command} endpoint (#24782)
The lookup-prompt-by-command endpoint's only frontend wrapper,
getPromptByCommand, is dead: nothing imports or calls it, the path is
referenced nowhere else, and the route handler has no internal caller
(slash-command resolution happens client-side from the loaded prompt
list). Removes the route handler, its section header, and the dead
wrapper.

The shared Prompts.get_prompt_by_command data-layer method is kept: it
is still used by create/update prompt validation (prompts.py:175, 275,
340). PromptAccessResponse and AccessGrants are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:28:07 +04:00
Timothy Jaeryang Baek c8f851bd2d refac
Co-Authored-By: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>
2026-05-20 00:26:22 +04:00
Dara Adib 8a104a7ab1 Run transcode_audio_to_mp3 in a thread to avoid blocking (#24876)
This incorporates the transcoding implementation in #24145.
2026-05-20 00:25:34 +04:00
Timothy Jaeryang Baek 2b99945d27 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-05-20 00:22:27 +04:00
Classic298 d07fd7d6d8 fix: disable redirect following in OAuth picture fetch (SSRF) (#24809)
_process_picture_url validated the initial picture URL with validate_url()
but then aiohttp followed 3xx redirects without re-validating the target,
so a validate_url-passing public URL could 302 to an internal address and
the body was base64-stored in the user's profile_image_url. This is the
sixth call site of the CVE-2026-45401 redirect-bypass cohort; the other
five already pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS. Apply
the same.
2026-05-19 23:57:38 +04:00
Classic298 854440f703 fix: mitigate DNS rebinding in web loader fetch paths (#24759)
validate_url() resolves DNS to check IPs but discards the result; the
HTTP client resolves again independently.  Between those two lookups an
attacker can swap the DNS record from a public IP to an internal one
(DNS rebinding).

Push the IP-is-global check into the actual connection layer so the
validated resolution is the one used for the TCP connect:

- aiohttp (_fetch): _SSRFSafeResolver wraps DefaultResolver and rejects
  non-global IPs at resolve time (zero TOCTOU window).
- requests (_scrape): _SSRFSafeAdapter mounts custom urllib3 connection
  classes whose _new_conn resolves, validates, and connects to the
  validated IP in one shot (zero TOCTOU window).

Both paths respect ENABLE_RAG_LOCAL_WEB_FETCH (skip validation when on).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 23:57:12 +04:00
Timothy Jaeryang Baek cfa6908d57 refac 2026-05-19 22:25:39 +04:00
Timothy Jaeryang Baek eb3076c1b0 refac 2026-05-19 22:14:46 +04:00
Classic298 d169f086da fix: respect access_type in shared-chat file authorization branch (#24755)
has_access_to_file granted access whenever the file was attached to a
shared chat the user could read, ignoring the requested access_type. A
read-only shared-chat recipient therefore satisfied write and delete
checks and could delete or mutate the chat owner's attached file. Gate
the shared-chat branch on read access, matching the channels branch
directly above it.

Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:09:56 +04:00
Algorithm5838 5d104abd08 fix: emit [DONE] for AsyncGenerator pipe returns (#24763) 2026-05-19 22:07:56 +04:00
Classic298 a803372805 fix: log expected fetch/transcript/tool-server failures as warnings (#24903) 2026-05-19 21:55:40 +04:00
Timothy Jaeryang Baek 56c0d00e13 enh: linkup 2026-05-19 21:54:38 +04:00
Timothy Jaeryang Baek 3c5e7968f0 refac 2026-05-19 21:48:44 +04:00
Timothy Jaeryang Baek 94b66b1797 refac 2026-05-19 21:35:12 +04:00
Timothy Jaeryang Baek ed73ef3d8d refac 2026-05-19 21:35:04 +04:00
Classic298 f02aeea0bb fix: validate Playwright navigations and gate redirects in web loader (#24756)
SafePlaywrightURLLoader validated only the initially submitted URL and
then let the browser follow HTTP redirects and client-side navigations
without re-checking them, so a public URL could redirect into the
internal network (cloud metadata, RFC1918, loopback). Intercept
document-type requests, re-run validate_url on each, and apply the same
redirect policy as the requests loader (blocked unless
AIOHTTP_CLIENT_ALLOW_REDIRECTS). Sub-resource requests pass through
unchanged so page rendering performance is unaffected.

Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:27:43 +04:00
Classic298 5401e8560b refactor: remove dead generateFollowUps frontend wrapper (#24794)
generateFollowUps in src/lib/apis/index.ts is dead: it appears only at
its own definition, nothing imports or calls it, and it targets a
non-existent path (/api/v1/tasks/follow_ups/completions, plural) while
the real route is /tasks/follow_up/completions (singular). Follow-up
suggestions are generated server-side in the chat-completion middleware
and delivered over the chat:message:follow_ups websocket event, so this
wrapper was never on the live path.

Removes only the dead wrapper. The backend POST /tasks/follow_up/completions
endpoint is intentionally kept: it is a member of the actively-used
/tasks/*/completions family (title, tags, emoji, queries, moa) and its
handler delegates to the core generate_follow_ups function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:27:19 +04:00
Classic298 ea058841c9 fix: check destination calendar write access on event update (#24764)
update_event only verified write access on the event's source calendar.
CalendarEventUpdateForm accepts a new calendar_id which the model layer
applies unconditionally, so a user with write access to their own calendar
could move (inject) an event into any other user's calendar. Mirror the
destination check create_event already performs.
2026-05-19 21:26:58 +04:00
Classic298 c48ac5163c fix: pass subscription_key and endpoint in bing.py CLI search_bing() call (#24768)
The __main__ block called search_bing() with 4 positional arguments, but
the function requires 5 (subscription_key, endpoint, locale, query,
count). Running `python -m open_webui.retrieval.web.bing` raised a
TypeError and, before failing, silently misrouted every argument. Read
the key/endpoint from environment variables, matching config.py defaults.

Closes #24765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:26:10 +04:00
Timothy Jaeryang Baek 2ca91ceeec refac: audio 2026-05-19 21:24:58 +04:00
Classic298 c306a7e16e refactor: remove unused DELETE /chats/{id}/tags/all endpoint (#24785)
The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:03:58 +04:00
Timothy Jaeryang Baek cc94a90b4d refac
Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
2026-05-19 21:03:23 +04:00
Timothy Jaeryang Baek 1fa3050f06 refac 2026-05-19 20:57:56 +04:00
Timothy Jaeryang Baek 1e36a20600 refac 2026-05-19 20:55:04 +04:00
Timothy Jaeryang Baek 0adc090dcb refac 2026-05-19 20:51:53 +04:00
Classic298 f216bfab63 refactor: remove unused POST /api/v1/utils/markdown endpoint (#24779)
POST /utils/markdown rendered a markdown string to HTML server-side. Its
only frontend wrapper, getHTMLFromMarkdown in src/lib/apis/utils/index.ts,
is dead: nothing imports or calls it, the route is hit by no other code
path, and the path string appears nowhere else in the repo (no direct
fetch, no test, no docs). Markdown is rendered client-side in the UI, so
this endpoint was redundant.

Fully self-contained removal: the endpoint, its MarkdownForm model, the
now-orphaned 'import markdown' in the utils router (used only here), and
the dead getHTMLFromMarkdown wrapper. Nothing else depends on any of them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:50:07 +04:00
Classic298 92e727b1fc refactor: remove unused GET /evaluations/feedbacks/all endpoint (#24778)
GET /evaluations/feedbacks/all returned the entire feedback table in a
single response (flagged as a Medium OOM risk for admins in
open-webui#22206). Its only frontend wrapper, getAllFeedbacks in
src/lib/apis/evaluations/index.ts, is dead: nothing imports or calls it
anywhere in the codebase. The endpoint is a redundant view-only twin of
GET /evaluations/feedbacks/all/export, which is what the admin Feedbacks
UI actually uses.

Removes the endpoint, the now-unused FeedbackResponse import in the
evaluations router, and the dead getAllFeedbacks frontend wrapper. The
shared Feedbacks.get_all_feedbacks data-layer method is kept, since the
live /feedbacks/all/export endpoint still uses it.

Ref: open-webui#22206

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:49:22 +04:00
Classic298 557df60c3b refactor: remove dead frontend API wrappers with no backend route (#24792)
These 19 exported wrappers are dead: each appears exactly once in the
codebase (its own definition), nothing imports or calls any of them, and
none has a corresponding backend route. They are leftovers from settings
that were consolidated server-side into /auths/admin/config,
/openai/config, /ollama/config and /api/config:

- index.ts: getModelFilterConfig, updateModelFilterConfig,
  getCommunitySharingEnabledStatus, toggleCommunitySharingEnabledStatus,
  getModelConfig, updateModelConfig (+ orphaned GlobalModelConfig type)
- auths: getSignUpEnabledStatus, toggleSignUpEnabledStatus,
  getDefaultUserRole, updateDefaultUserRole, getJWTExpiresDuration,
  updateJWTExpiresDuration
- openai: getOpenAIUrls, updateOpenAIUrls, getOpenAIKeys, updateOpenAIKeys
- ollama: updateOllamaUrls
- prompts: restorePromptFromHistory
- folders: updateFolderItemsById (+ orphaned FolderItems type)

Shared types (ModelConfig/ModelMeta/ModelParams) and all live wrappers
are untouched. Removal is import-safe: nothing referenced these.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:48:02 +04:00
Timothy Jaeryang Baek 7e9d41d664 refac 2026-05-19 20:44:38 +04:00
Aindriú Mac Giolla Eoin 4f89617192 i18n: update Irish translation (#24883) 2026-05-19 20:41:19 +04:00
Timothy Jaeryang Baek aa06200f78 refac 2026-05-19 20:37:53 +04:00
Timothy Jaeryang Baek c75fe8e74b fix: get_image_base64_from_file_id 2026-05-19 20:33:46 +04:00
Timothy Jaeryang Baek 0367ad54c5 chore: pyodide 2026-05-19 20:21:01 +04:00
Timothy Jaeryang Baek ef975649b2 refac 2026-05-19 20:19:23 +04:00
Timothy Jaeryang Baek d310a0777c refac 2026-05-19 20:12:00 +04:00
Classic298 359590ca9d fix: wire workspace.skills into the sidebar + workspace-index gates (#24729)
Reported by bwgabrielsusai on #24719: granting a user only
`workspace.skills` doesn't show the Workspace menu, and visiting
`/workspace` directly bounces them to `/`.

The per-route guard in `/workspace/+layout.svelte` already covered
skills, but two earlier gates in the chain didn't:

* `Sidebar.svelte` case 'workspace' OR'd models/knowledge/prompts/tools
  to decide menu visibility — skills was missing, so the entry never
  rendered for skills-only users.
* `/workspace/+page.svelte` redirect chain picked the first available
  section — skills was missing, so the fallback `goto('/')` fired.

Adding skills to both.
2026-05-15 09:34:23 +09:00
G30 eba1540a95 feat(ui): add emoji picker to rich text formatting toolbar (#24704) 2026-05-15 09:32:56 +09:00
Algorithm5838 ae48838b04 fix: tag composite pk in migration (#24722) 2026-05-15 09:31:27 +09:00
Classic298 6360af36d8 Update SECURITY.md (#24726) 2026-05-15 09:30:48 +09:00
mayamsin 1ded5ce969 i18n: update Russian translations (#24728) 2026-05-15 09:30:10 +09:00
Classic298 0cd1529008 fix: default optional env vars used with bash ,, in start.sh (#24683)
start.sh runs with `set -euo pipefail`, but three call sites added in
070ab2650 (refac: reorganize scripts and ci workflows) reference
optional env vars via bash's `,,` lowercase expansion without any
default. Containers that don't set these vars — the default for every
deployment that isn't explicitly opting into Playwright / bundled
Ollama / CUDA — crash on startup with:

    start.sh: line 15: WEB_LOADER_ENGINE: unbound variable

(and the same for USE_OLLAMA_DOCKER, USE_CUDA_DOCKER once the first
were set in turn.) Reported in open-webui#24560 by urbenlegend.

The same refactor correctly defaulted every other optional env var
with `${VAR:-…}`. The three `,,` references slipped through because
bash can't combine `:-default` with `,,` in a single substitution —
`${VAR:-default,,}` makes the default literal `,,`, not what's wanted.

Fix: normalise the three vars in a one-line preamble with `${VAR:=}`,
which assigns an empty default if unset. The downstream `${VAR,,}`
expressions stay exactly as Tim wrote them, preserving the file's
visual style and matching the existing `${VAR:-…}` idiom for "this
variable is optional".
2026-05-15 09:29:25 +09:00
Timothy Jaeryang Baek 9263b7568e refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-05-14 14:12:42 +09:00
Timothy Jaeryang Baek 73d2065227 fix: legacy peewee tables fk 2026-05-14 14:06:46 +09:00
Timothy Jaeryang Baek d7cfc1e46a refac 2026-05-14 14:06:32 +09:00
Timothy Jaeryang Baek 9717ada92f refac 2026-05-14 14:05:28 +09:00
Timothy Jaeryang Baek 9a8969ca93 refac 2026-05-14 13:49:50 +09:00
Timothy Jaeryang Baek 2e1b671e8d refac 2026-05-14 13:49:15 +09:00
Timothy Jaeryang Baek db2b3d7fd8 refac 2026-05-14 13:46:54 +09:00
Timothy Jaeryang Baek 1004dad274 refac 2026-05-14 13:46:36 +09:00
Timothy Jaeryang Baek ee3b14233a refac 2026-05-14 13:46:23 +09:00
Timothy Jaeryang Baek dc0f8ae6f2 refac 2026-05-14 13:46:08 +09:00
Timothy Jaeryang Baek 1b9d22e324 refac 2026-05-14 13:45:59 +09:00
Timothy Jaeryang Baek 98d3b23085 refac 2026-05-14 13:45:39 +09:00
Timothy Jaeryang Baek 6b1df94bf9 refac 2026-05-14 13:45:31 +09:00
Timothy Jaeryang Baek 95840e307a refac 2026-05-14 13:45:21 +09:00
Timothy Jaeryang Baek 6df09a4039 refac 2026-05-14 13:45:12 +09:00
Timothy Jaeryang Baek 459b1c3fda refac 2026-05-14 13:45:05 +09:00
Timothy Jaeryang Baek bd9f82d5a6 refac 2026-05-14 13:44:50 +09:00
Timothy Jaeryang Baek e5c8f8110a refac 2026-05-14 13:19:00 +09:00
Timothy Jaeryang Baek 9c14740ffb refac 2026-05-14 13:12:59 +09:00
Timothy Jaeryang Baek 74ae177d79 refac 2026-05-14 13:10:37 +09:00
Timothy Jaeryang Baek 1ea54c3217 refac 2026-05-14 13:10:22 +09:00
Timothy Jaeryang Baek f0e88dadc8 refac 2026-05-14 13:08:53 +09:00
Classic298 5cc1eb5170 fix: enforce features.direct_tool_servers on chat-completion tool_servers (#24693)
* fix: enforce features.direct_tool_servers on chat-completion tool_servers

The features.direct_tool_servers per-user permission was correctly
enforced on the storage path (routers/users.py user/settings/update,
which strips toolServers from saved settings when the caller lacks the
permission), but the inference path (/api/chat/completions) popped
tool_servers straight from the request body into metadata with no
permission check. The middleware (utils/middleware.py:2799) then
consumed direct_tool_servers to inject system_prompt into the message
array and register external tool specs that get invoked during the
completion. End result: any authenticated user could bypass the
admin-set per-user feature toggle and use inline tool_servers in their
chat-completion requests, even when admin had explicitly denied the
permission.

Default for USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS is False
(config.py:2750), so under default config no regular user is supposed
to be able to use direct tool servers — making this a real boundary
bypass on out-of-the-box deployments rather than a corner case.

Mirror the storage-side behaviour at the inference entry point: pop
tool_servers from the request body, then silently drop the value if
the caller is non-admin and lacks features.direct_tool_servers. Admins
always pass; users with the explicit grant always pass; everyone else
gets None propagated into metadata, which the middleware already
handles as the no-tool-servers case.

Reported by berkant-koc in GHSA-f582-c373-jjf6.

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* chore: trim verbose comment on tool_servers permission check

---------

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>
2026-05-14 13:08:33 +09:00
Timothy Jaeryang Baek ae06e199d5 refac 2026-05-14 03:25:54 +09:00
Timothy Jaeryang Baek cc16e06c32 refac 2026-05-14 03:10:48 +09:00
Timothy Jaeryang Baek 81f611fb73 refac 2026-05-14 03:06:37 +09:00
Timothy Jaeryang Baek 245e0ee029 refac 2026-05-14 03:01:48 +09:00
Classic298 ba78ebea27 fix: validate folder_id ownership on chat create + folder-update endpoints (#24588)
POST /api/v1/chats/new and POST /api/v1/chats/{id}/folder accepted a
caller-supplied folder_id with no validation — neither ownership, nor
existence, nor UUID format. The row was persisted with the supplied
value verbatim, so the DB ended up with chat rows whose folder_id
referenced another user's folder, a non-existent UUID, or even a
non-UUID string.

No read path surfaces this across users — every chat-folder read is
user_id-filtered on both sides — so this is referential-integrity
hardening rather than a security boundary fix. But there's no reason
to accept dangling references either, and the downstream consumers
shouldn't have to assume the column is clean.

Add a Folders.get_folder_by_id_and_user_id() lookup at both writers:
if a folder_id is supplied, it must match a folder owned by the
caller. None remains allowed (chat-without-folder is the default).
Non-existent and non-UUID values fall through to 404.

Reported by ShigekiTsuchiyama in GHSA-4vrg-2vcq-q7jc.

Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>
2026-05-14 02:57:57 +09:00
Timothy Jaeryang Baek f607337582 refac 2026-05-14 02:56:44 +09:00
Aleix Dorca 2a7524056a i18n: Update catalan translation.json (#24569) 2026-05-14 02:49:32 +09:00
Timothy Jaeryang Baek 0214c8db24 fix: korean i18n 2026-05-14 02:41:54 +09:00
Timothy Jaeryang Baek 8aa2a42dc7 refac 2026-05-14 02:40:20 +09:00
Asbjørn Dyhrberg Thegler f6d8e1e38d Fix translation for 'Authentication' in Danish (#24645) 2026-05-14 02:36:43 +09:00
Timothy Jaeryang Baek 7dcd932ad7 refac 2026-05-14 02:34:19 +09:00
Daniel Nylander be301c1c27 i18n: Update Swedish (sv-SE) translation — merge with latest dev (#24665)
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
2026-05-14 02:30:10 +09:00
Taey 5ab32a5086 fix: German i18n translations (#24668) 2026-05-14 02:29:33 +09:00
Timothy Jaeryang Baek 42c86e7283 refac 2026-05-14 02:25:16 +09:00
Timothy Jaeryang Baek 74f95a9b0d refac 2026-05-14 02:08:35 +09:00
Timothy Jaeryang Baek 0b81520e07 refac 2026-05-14 02:08:01 +09:00
Timothy Jaeryang Baek 4e78b355ef refac 2026-05-14 02:00:23 +09:00
Timothy Jaeryang Baek d74ee34d91 refac 2026-05-14 01:49:34 +09:00
Timothy Jaeryang Baek 3127f1b462 refac 2026-05-14 01:43:33 +09:00
Timothy Jaeryang Baek ab0ee858b7 refac 2026-05-14 00:07:55 +09:00
Timothy Jaeryang Baek 32a417bbf6 refac 2026-05-13 22:52:09 +09:00
Timothy Jaeryang Baek e7d2ddbb1d refac 2026-05-13 22:51:23 +09:00
Timothy Jaeryang Baek 171150c1e1 refac 2026-05-13 22:46:00 +09:00
Timothy Jaeryang Baek 2ad327a4dc refac 2026-05-13 22:44:17 +09:00
Timothy Jaeryang Baek d6dae36da0 refac 2026-05-13 22:39:51 +09:00
Timothy Jaeryang Baek c2cbc47ca7 feat: knowledge directory 2026-05-13 22:37:53 +09:00
_00_ 8ace73da84 upd:i18n: es-ES Translation update v0.9.5 (#24651)
es-ES Translation. Update v0.9.5

Added translation of new strings.
2026-05-13 21:45:44 +09:00
Timothy Jaeryang Baek 3b00e5721a refac 2026-05-13 16:02:11 +09:00
Timothy Jaeryang Baek 2f642754ac refac 2026-05-13 16:00:56 +09:00
Timothy Jaeryang Baek 9ef579ce4b refac 2026-05-13 15:58:52 +09:00
Timothy Jaeryang Baek ecec86dd32 refac 2026-05-13 15:55:21 +09:00
Timothy Jaeryang Baek 5b125c24d4 feat: kb_exec 2026-05-13 15:41:30 +09:00
Timothy Jaeryang Baek bc244fdc90 refac 2026-05-13 12:44:12 +09:00
Timothy Jaeryang Baek 6d0295588e refac: modernize type annotations (PEP 604 / PEP 585) 2026-05-12 17:10:15 +09:00
Timothy Jaeryang Baek a59c967d7e refac: modernize imports, standardize type hints and docstrings 2026-05-12 06:30:38 +09:00
Timothy Jaeryang Baek 998c86a52b style: standardize os.environ.get to os.getenv 2026-05-12 06:12:16 +09:00
Timothy Jaeryang Baek 83736e8dbd style: ruff format 2026-05-12 06:12:06 +09:00
Timothy Jaeryang Baek 4cee7c29a5 refac 2026-05-12 06:01:12 +09:00
Timothy Jaeryang Baek 1413ce4a52 refac 2026-05-12 05:30:25 +09:00
Timothy Jaeryang Baek c79ff81f55 refac: remove dead peewee connection wrappers 2026-05-12 04:14:19 +09:00
Timothy Jaeryang Baek 2c2d06c31b refac: deprecate peewee migration layer
The Alembic init migration (7e5b5dc7342b) already creates the
equivalent schema. Peewee migrations are no longer needed for
any version >= 0.3.6.
2026-05-12 03:45:59 +09:00
Timothy Jaeryang Baek 4527c747b4 chore: remove unused legacy test suite 2026-05-12 03:35:51 +09:00
Timothy Jaeryang Baek 9c18e707ce chore: remove unused cypress tests 2026-05-12 03:31:57 +09:00
Timothy Jaeryang Baek 070ab26501 refac: reorganize scripts and ci workflows 2026-05-12 03:26:18 +09:00
Timothy Jaeryang Baek 90a02e623f refac 2026-05-12 03:04:35 +09:00
Timothy Jaeryang Baek 18ff358c15 refac 2026-05-11 03:30:12 +09:00
Timothy Jaeryang Baek 41b48b54aa doc: changelog 2026-05-11 02:54:22 +09:00
Timothy Jaeryang Baek 4856ce48be chore: format 2026-05-11 02:51:59 +09:00
Timothy Jaeryang Baek 0037baeb26 enh: channels streaming agent 2026-05-11 02:50:30 +09:00
Timothy Jaeryang Baek c951b4f262 chore: format 2026-05-11 02:29:13 +09:00
Timothy Jaeryang Baek 39777e35d8 doc: changelog 2026-05-11 02:28:38 +09:00
Timothy Jaeryang Baek 15e696691c refac 2026-05-11 02:25:11 +09:00
Timothy Jaeryang Baek 3a21b334cc refac 2026-05-11 02:15:46 +09:00
Timothy Jaeryang Baek 2dbf7b6764 refac 2026-05-11 02:12:38 +09:00
Timothy Jaeryang Baek 4a1064cefd refac 2026-05-11 02:04:09 +09:00
Timothy Jaeryang Baek 315566064a refac 2026-05-11 01:57:50 +09:00
Timothy Jaeryang Baek 3bba1c2270 feat: add IFRAME_CSP env var for srcdoc iframe content security policy
Adds an IFRAME_CSP environment variable that injects a Content-Security-Policy
<meta> tag into all srcdoc iframes rendering untrusted content:
- Artifacts (LLM-generated HTML previews)
- FullHeightIframe (tool/embed output)
- FilePreview (user-uploaded HTML files)
- CitationModal (RAG document HTML)

Shared utility in src/lib/utils/csp.ts handles injection with HTML-safe
attribute escaping. URL-based iframes (src=) are correctly excluded.

Env-var only — no PersistentConfig, no admin UI, no DB. Set once at deploy
time, requires restart. Empty string (default) means no CSP restriction.
2026-05-11 01:56:02 +09:00
Timothy Jaeryang Baek 1388f4568b refac 2026-05-11 01:46:33 +09:00
Timothy Jaeryang Baek d1ef538237 refac 2026-05-11 01:31:46 +09:00
Classic298 fc94118b2d fix: prevent mass-assignment user_id spoofing in POST /api/v1/evaluations/feedback (#24508)
* fix: prevent mass-assignment user_id spoofing in POST /api/v1/evaluations/feedback

Two independent gaps in backend/open_webui/models/feedbacks.py let an
authenticated caller forge the `user_id` (and `id`, `version`) on a new
feedback record submitted to POST /api/v1/evaluations/feedback:

1. `FeedbackForm` declared `model_config = ConfigDict(extra='allow')`,
   so Pydantic preserved any extra fields supplied in the request body —
   including `user_id`, `id`, `version`. The form is the public input
   boundary for the endpoint and should not accept unknown fields.

2. In `insert_new_feedback`, the dict literal placed
   `**form_data.model_dump()` AFTER `'id': id`, `'user_id': user_id`,
   `'version': 0`. Python dict-literal duplicate-key resolution is
   last-wins, so any of those fields present in `form_data` overwrote
   the server-derived values.

Combined effect: a regular user could POST a feedback record with an
arbitrary `user_id`, attributing the rating to any other user. The Elo
leaderboard at backend/open_webui/routers/evaluations.py computes model
rankings from these records, and the admin export
(GET /api/v1/evaluations/feedbacks/export) and admin list
(GET /api/v1/evaluations/feedbacks/all) display the spoofed attribution.

Two fixes, defense-in-depth:

- FeedbackForm: switch `extra='allow'` to `extra='ignore'` so Pydantic
  drops unknown fields at parse time. Sub-models (RatingData / MetaData /
  SnapshotData) intentionally keep `extra='allow'` because their contents
  are deliberately schema-flexible — the spoofing surface was the form,
  not the sub-payloads.

- insert_new_feedback: spread `form_data.model_dump()` first, then
  overlay server-controlled fields (`id`, `user_id`, `version`,
  `created_at`, `updated_at`) so the explicit keys win on duplicate-key
  resolution regardless of what reaches the function. Matches the secure
  pattern already used in backend/open_webui/models/functions.py:120.

Reported by yantongggg in GHSA-rjmp-vjf2-qf4g. Same root-cause class as
the prior published GHSA-hr43-rjmr-7wmm (folder mass-assignment, fixed
in v0.9.0); that fix did not generalize across the codebase, this fix
closes the feedback variant.

Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>

* chore: trim comments

---------

Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>
2026-05-11 01:16:17 +09:00
Timothy Jaeryang Baek 8d3133fe28 refac 2026-05-11 01:15:34 +09:00
Classic298 d11e06f1b7 fix: prevent redirect-based SSRF and enforce collecton write access (#24524)
* fix: prevent redirect-based SSRF in get_image_base64_from_url

Cohort follow-up to PR #24491. That PR patched three call sites
(SafeWebBaseLoader._scrape, get_content_from_url, load_url_image) to
pass allow_redirects=False on the underlying HTTP client; this fourth
call site in utils/files.py was missed.

get_image_base64_from_url() is invoked from convert_url_images_to_base64
in utils/middleware.py on every /api/chat/completions request whose
message content includes an image_url part. validate_url() is called on
the originally-submitted URL only; the aiohttp session.get() call had
no allow_redirects argument and the shared session pool does not
override the aiohttp default (allow_redirects=True). An authenticated
user sending a chat message with image_url pointing at an attacker host
that 302-redirects to 169.254.169.254 / 127.0.0.1 / RFC1918 reached the
internal target. This is the most reachable variant in the redirect
cluster: no special endpoint, no admin permission, no feature flag.

Apply the same one-line fix as the other three call sites: pass
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS (defaults to False).

Reported by nayakchinmohan in GHSA-88jq-grjp-jx6f; consolidated under
GHSA-rh5x-h6pp-cjj6.

Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com>

* fix: enforce collection write access on process_file endpoint

Cohort follow-up to ba83613ff. That commit added _validate_collection_access
to process_text and process_web (the user-supplied collection_name path)
but missed process_file in the same router.

process_file accepts a user-supplied collection_name and writes the file's
embedded content into that collection via save_docs_to_vector_db. The
file_id is gated by file ownership (line 1562) but collection_name was
unchecked, so an authenticated user could append content from a file they
own into another user's knowledge-base collection by passing the victim's
KB UUID as collection_name. Identical pattern to the process_text and
process_web gaps that ba83613ff closed.

Apply the same one-line gate as the sibling endpoints: when
collection_name is user-supplied (not the default file-{file.id} fallback),
require write access via _validate_collection_access. The shared validator
delegates to filter_accessible_collections, which already correctly
handles file-* prefixes (via has_access_to_file) and KB UUIDs
(via Knowledges.check_access_by_user_id) — admins bypass.

Reported by tenbbughunters (Tenable) in GHSA-4g37-7p2c-38r9 (the
comprehensive write-path filing covering process_text / process_file /
process_web / process_youtube and the _validate_collection_access UUID
root cause), and independently re-identified for the missed process_file
call site by kodareef5 in GHSA-4m74-3cmc-293g.

Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>

* fix: enforce collection write access on process_files_batch endpoint

Cohort follow-up to ba83613ff and the prior process_file fix on this
branch. process_files_batch (line 2604) is the third write endpoint in
the same router that accepts a user-supplied collection_name; it was
covered in the same Tenable filing as process_file and was missed by
the same cohort fix. The endpoint validates per-file ownership at line
2642 but does not check whether the caller has write access to the
target collection_name before save_docs_to_vector_db writes into it
at line 2683-2690 with add=True.

Apply the same one-line gate as the sibling endpoints. Validate only
when collection_name is user-supplied (truthy) so the existing fall
through behavior for the None case is unchanged.

Same Tenable / kodareef5 cohort as the previous commit.

Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>

---------

Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com>
Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>
2026-05-11 01:09:15 +09:00
Classic298 841c9045d7 fix: gate tool content updates behind workspace.tools to match create endpoint (#24513)
* fix: gate tool content updates behind workspace.tools to match create endpoint

`update_tools_by_id` (routers/tools.py:452) authorizes a caller as long as
they are the tool's owner, hold a `write` access grant on the tool, or are
an admin. This means a verified user who has been given a write grant on
a tool — typically as part of a metadata-collaboration workflow (edit
description, adjust valves, manage access grants) — can also overwrite
the tool's Python source. Because `load_tool_module_by_id` further down
calls `exec(content, module.__dict__)` at module-import time, anything
the new content puts outside the `class Tools:` body executes immediately
on the server with the worker's privileges (root in the default Docker
deployment).

The `create_new_tools` endpoint already requires
`workspace.tools` (or `workspace.tools_import`) precisely because creating
a tool means submitting executable code. The update endpoint did not
mirror that check, producing an asymmetric authorization surface in which
a write-grantee with no workspace permission can still reach the same
exec sink as a workspace.tools-trusted creator. SECURITY.md frames
`workspace.tools` as the trust signal an admin uses to delegate
code-execution capability; the previous behavior let that signal be
bypassed by a per-resource share.

Fix: after the existing ownership / write-grant / admin gate, add a
content-change check. If `form_data.content != tools.content`, require
`workspace.tools` or `workspace.tools_import` (or admin role). Metadata
edits — `name`, `description`, valves config, access grants — continue
to flow through the existing gate, so the legitimate share-for-
collaboration workflow is unaffected.

Reported by KadirArslan in GHSA-p4fx-23fq-jfg6 with a working three-user
PoC (Alice trusted with workspace.tools creates a tool and shares write
to Bob; Bob updates content and the new code runs as root inside the
container, with Burp Collaborator confirming outbound exfiltration).

Co-authored-by: KadirArslan <KadirArslan@users.noreply.github.com>

* chore: trim comment

---------

Co-authored-by: KadirArslan <KadirArslan@users.noreply.github.com>
2026-05-11 01:08:12 +09:00
Classic298 f5e110fbee fix: enforce message ownership in group/DM channel update + delete endpoints (#24506)
* fix: enforce message ownership in group/DM channel update + delete endpoints

`update_message_by_id` (channels.py:1348) and `delete_message_by_id`
(channels.py:1550) branch on `channel.type`. The `else` branch (standard
channels) correctly enforces `message.user_id != user.id` ownership before
mutating, but the `if channel.type in ['group', 'dm']` branch only checked
`is_user_channel_member` — channel membership alone, with no message
ownership verification.

Effect on group/DM channels: any verified member of the conversation could:

- overwrite another member's message content while the server preserved
  `user_id=victim`, producing tampered content that renders to other
  members as the original author's authentic post (integrity + authenticity);
- silently delete another member's messages, removing them from
  conversation history without trace (integrity).

Reproduced end-to-end against v0.9.4 with three users (attacker, victim,
viewer) sharing a group channel: attacker overwrites victim's message and
deletes another, viewer reads the tampered content as victim-authored.

Two patches, identical shape, mirror the `else` branch's existing
ownership semantics:

- `update_message_by_id` group/DM branch: add
  `if user.role != 'admin' and message.user_id != user.id: raise 403`
  immediately after the `is_user_channel_member` check.
- `delete_message_by_id` group/DM branch: same.

The standard-channel branch is unchanged (it already enforced ownership).
Admins remain able to moderate any message, matching the existing semantic
in the standard-channel branch.

Reports consolidated under GHSA-wwhq-cx22-f7vv (earliest live filing of the
group/DM-specific variant). Same gap previously surfaced and partially
fixed under GHSA-jxwr-g6r6-j3fx (which addressed the standard-channel
branch only) — this completes the cohort.

* chore: trim comments
2026-05-11 01:03:39 +09:00
Timothy Jaeryang Baek e8e9141061 refac 2026-05-11 01:02:45 +09:00
Classic298 d3737176bc fix: require write permission for pin_channel_message on standard channels (#24521)
`pin_channel_message` (channels.py:1242) checked `permission='read'` on
the standard-channel branch before mutating `is_pinned` / `pinned_by` /
`pinned_at` via `Messages.update_is_pinned_by_id`. Pin/unpin is a write
operation; gating it on read access let any user with read-only channel
access pin or unpin any message in the channel, including admin posts.

One-character fix: change `permission='read'` to `permission='write'`.

Reported by kikayli in GHSA-5gc6-xhv4-2wg6.

Co-authored-by: kikayli <kikayli@users.noreply.github.com>
2026-05-11 00:59:50 +09:00
Classic298 2d9939ed49 chore: add validate_url() to get_image_data() for cohort consistency hardening (#24518)
* chore: add validate_url() to get_image_data() for cohort consistency hardening

`get_image_data()` in `backend/open_webui/routers/images.py` fetches the
URL returned by the configured image generation API directly via
`session.get(data)` without first calling `validate_url()`. The sibling
`load_url_image()` in the same file (called from /images/edit) calls
`validate_url(data)` first — that gate was added under
GHSA-jgx9-jr5x-mvpv. The two functions handle structurally identical
input (an attacker-or-server-supplied URL string) and should enforce the
same SSRF gate as a matter of code hygiene.

In the current call graph, the URL passed to `get_image_data()` comes
from the admin-configured image generation API's response, so an
exploitable SSRF chain additionally requires admin-side trust delegation
(misconfigured/untrusted upstream image API, or a custom
OpenAI-compatible server that reflects user input into response URLs).
That makes the missing call a defense-in-depth gap rather than a
vulnerability per SECURITY.md Rule 9 — a position the GHSA-h7cc-wwjp-5xqh
advisory is being closed under. This change is hardening: it brings the
two image-fetch helpers into alignment so any future caller that begins
passing user-influenced URLs into `get_image_data()` is gated by the
same private-IP / loopback / metadata-IP filter the rest of the
codebase enforces.

Surface raised by brodmart in GHSA-h7cc-wwjp-5xqh.

Co-authored-by: brodmart <brodmart@users.noreply.github.com>

* chore: trim comment

---------

Co-authored-by: brodmart <brodmart@users.noreply.github.com>
2026-05-11 00:59:32 +09:00
Classic298 c66c273f62 fix: strip model params for read-only callers on per-id endpoint (#24525)
GET /api/v1/models/model?id=<model_id> at routers/models.py:412
returned the full model.model_dump() to any caller with read access,
including the params dict that holds the admin-curated system prompt
and other behavior config. The user-facing /api/models endpoint
already strips this via utils/models.py:170,210 with the comment
"Remove params to avoid exposing sensitive info", and /api/v1/models/list
gates by write permission so non-curators don't see the model in their
workspace listing at all. The per-id endpoint missed the same gate, so
a user with read-only access (e.g. granted access to use the model in
chat) could open /workspace/models/edit?id=<not-mine> in the browser
and read the system prompt verbatim from the network response, even
though saving was correctly blocked.

Compute write_access once at the top of the handler so it can serve
both the response-shape decision and the response field. When the
caller lacks write access, replace params with an empty dict in the
serialised response. Owners, admins under BYPASS_ADMIN_ACCESS_CONTROL,
and explicit write-grant holders still get the full payload so the
workspace edit UI keeps working for users who legitimately curate the
model.

Read-permission users continue to receive everything else they need to
chat with the model — the chat path resolves prompt/params server-side
from the stored ModelModel and never echoes them back through this
endpoint.

Reported by destination-one in GHSA-h2cw-7qw9-56xr.

Co-authored-by: destination-one <destination-one@users.noreply.github.com>
2026-05-11 00:59:08 +09:00
Classic298 e7ba8978c6 fix: reject parser-confusing chars in validate_url to close SSRF bypass (#24534)
urllib.parse.urlparse and requests/aiohttp disagree on how to split URLs
containing backslash, tab, CR, or LF in or around the netloc. urlparse
treats backslash as part of userinfo and uses what follows '@' as the
host; requests treats backslash as the start of the path and connects
to whatever precedes it. The same URL therefore passes the private-IP
filter (urlparse sees a public host) but reaches an internal target
(requests connects to e.g. 127.0.0.1). End result is an SSRF that the
existing IP block list cannot catch because it's evaluating the wrong
host.

PoC: http://127.0.0.1:6666\@1.1.1.1 — urlparse hostname is 1.1.1.1
(global, passes), requests reaches 127.0.0.1 (loopback).

Reject up front any URL containing one of the four documented parser-
confusing characters before either parser gets a chance to interpret
it. None of these characters is valid in an unencoded URL (\ should
always be %5C, whitespace should be %09 / %0A / %0D), so this is a
pure defensive rejection with no legitimate-input false positives.

Reported by Fushuling and RacerZ-fighting in GHSA-8w7q-q5jp-jvgx.

Co-authored-by: Fushuling <Fushuling@users.noreply.github.com>
Co-authored-by: RacerZ-fighting <RacerZ-fighting@users.noreply.github.com>
2026-05-11 00:57:48 +09:00
joaoback 5b13e3e3f0 i18n: add pt-BR translations for newly added UI items and consistency pass (#24503)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-05-11 00:57:18 +09:00
Timothy Jaeryang Baek b0a56375d2 chore 2026-05-09 23:38:32 +09:00
Timothy Jaeryang Baek 8689f7090f chore: format 2026-05-09 23:22:05 +09:00
Timothy Jaeryang Baek 55535a8965 doc: changelog 2026-05-09 23:21:08 +09:00
Classic298 203ec29baf chore: remove unauthenticated dead-code GET /api/v1/retrieval/ status endpoint (#24497)
The `get_status()` handler at retrieval.py:263 (`@router.get('/')`) returned
the live RAG pipeline configuration (CHUNK_SIZE, CHUNK_OVERLAP, RAG_TEMPLATE,
RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, RAG_RERANKING_MODEL, etc.) without
any authentication dependency, while every adjacent endpoint on the same
router (/embedding, /embedding/update, /config, /config/update) requires
get_admin_user.

Exhaustive search of the repository confirms the endpoint has no callers:

- Frontend (src/): no `RETRIEVAL_API_BASE_URL}/'`-style fetch; the existing
  `getRAGConfig()` in src/lib/apis/retrieval/index.ts targets `/config`,
  not the root, and is the only consumer of admin-level retrieval state.
- Backend self-references: none.
- Cypress e2e (chat, documents, registration, settings): none.
- Backend tests (backend/open_webui/test/): none.
- Build/CI scripts (scripts/): none.
- Direct symbol import of `get_status` from this router: none.

The endpoint is dead code, almost certainly a relic from before the
/config GET split. Removing it has zero UX impact and eliminates the
unauthenticated-config-disclosure surface raised in advisory triage on
GHSA-65pg-qhhw-mxwg. External monitoring scripts that may have hit the
bare root will receive a 404 and can switch to the existing /config
endpoint, which returns the same fields plus the rest of the RAG config
under admin auth.

Surface raised by 0xRyuzak1 in GHSA-65pg-qhhw-mxwg. The advisory was closed
as not-a-vulnerability per SECURITY.md Rule 1 (no security boundary
crossed in default config — RAG_TEMPLATE default is a citation-format
instruction, not a system prompt; no integrity/availability impact); this
removal is independent code-hygiene that aligns the router cohort.

Reported-by: 0xRyuzak1 <https://github.com/0xRyuzak1>
2026-05-09 23:19:14 +09:00
Classic298 9918ab6265 fix: gate public sharing of skills behind sharing.public_skills on create/update (#24494)
The /create (L155-193) and /id/{id}/update (L248-297) endpoints in
routers/skills.py persisted form_data.access_grants directly to
AccessGrants.set_access_grants without filter_allowed_access_grants,
while every other shareable resource in the codebase (channels, knowledge,
models, notes, prompts, tools, calendars) and the dedicated
/id/{id}/access/update endpoint on this same router (L309-348) all do call
the filter. A user with workspace.skills permission (default False, but
admins can grant it to skill-creating users) could therefore attach
{"principal_type":"user","principal_id":"*","permission":"read"|"write"}
to the create or update payload and have it persisted unfiltered, bypassing
the sharing.public_skills gate that the rest of the cohort enforces.

Two changes:

- create_new_skill: call filter_allowed_access_grants with
  'sharing.public_skills' immediately before insert, after the existing
  permission check and ID-taken check.
- update_skill_by_id: call filter_allowed_access_grants with the same key
  after the access check, before form_data.model_dump() flows into
  Skills.update_skill_by_id. The pre-existing access check at L263-277 only
  restricts WHO may modify the skill; the new filter restricts WHICH grants
  they may set.

All supporting plumbing was already in place from prior PRs:
filter_allowed_access_grants is already imported at L22, the
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING constant exists,
DEFAULT_USER_PERMISSIONS['sharing']['public_skills'] is wired up,
SharingPermissions.public_skills is in the Pydantic, and the admin UI
already renders the toggle. This is a pure 2-line router fix that closes
the cohort-consistency gap.

Same shape as the calendar fix in #24493, reported by Matteo Panzeri while
auditing the resource-cohort cohort during follow-up on #24493.

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
2026-05-09 23:19:03 +09:00
Classic298 8a0018cf96 fix: gate public sharing of calendars behind sharing.public_calendars permission (#24493)
* fix: gate public sharing of calendars behind sharing.public_calendars permission

The calendar router did not call filter_allowed_access_grants on either the
create or update endpoint, while every other shareable resource in the
codebase (channels, knowledge, models, notes, prompts, skills, tools) does.
A verified non-admin owner could therefore attach
`{"principal_type":"user","principal_id":"*","permission":"read"|"write"}`
to their own calendar in the create or update payload and have it persisted
unfiltered. Any other verified user with the (default-on) features.calendar
permission could then read or, for write grants, write events on it via the
existing /events* endpoints, bypassing the per-user sharing.public_<X>
permission gate the rest of the resource cohort enforces.

Three changes:

- config.py: add USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING (default
  False, env-overridable) and surface it in DEFAULT_USER_PERMISSIONS
  ['sharing']['public_calendars'] so admins can grant it per group via the
  same UI used for notes/models/etc.
- routers/calendar.py: import filter_allowed_access_grants and call it in
  create_calendar with the new sharing.public_calendars key, identical to
  the channel router's pattern.
- routers/calendar.py: call filter_allowed_access_grants in update_calendar
  too. The pre-existing owner-only gate at L350 only restricts WHO may
  change grants; the new filter restricts WHICH grants they may set, so a
  non-admin owner cannot make their own calendar publicly readable or
  writable without the corresponding sharing permission.

Same shape as GHSA-7rjh-px4v-5w55 (channels). Reported by Matteo Panzeri.

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>

* fix: expose public_calendars + features.calendar through admin permissions surface

The earlier commit added DEFAULT_USER_PERMISSIONS['sharing']['public_calendars']
and the runtime filter call, but the new key was not yet plumbed through the
admin /users/default/permissions endpoint. Without these changes the toggle
would round-trip as silently dropped:

- routers/users.py SharingPermissions: any payload POSTed to
  /default/permissions ran through `form_data.model_dump()`, and Pydantic
  drops fields not declared on the model. The new public_calendars key
  would have been stripped on every save, leaving admins unable to grant
  the permission via the UI even though the runtime filter would honor it.
- src/lib/constants/permissions.ts: the frontend's DEFAULT_PERMISSIONS dict
  is the seed shape used by the admin Groups Permissions panel; without
  the new key it could not bind a Switch component to it.
- Permissions.svelte: add a Calendars Public Sharing toggle alongside the
  Notes/Chats Public Sharing toggles, gated on the existing
  features.calendar flag (matches the pattern used for notes/chats).

Also closes a pre-existing parity gap on features.calendar: DEFAULT_USER_
PERMISSIONS['features']['calendar'] has existed since the calendar feature
shipped, and Permissions.svelte already renders a Calendar feature toggle,
but FeaturesPermissions Pydantic and the frontend defaults never knew
about it. Adding it everywhere completes the round-trip so admin saves no
longer silently drop the calendar feature flag either.

---------

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
2026-05-09 23:18:51 +09:00
Timothy Jaeryang Baek 69270e1c9e doc: changelog 2026-05-09 21:08:07 +09:00
Timothy Jaeryang Baek 2e71b3fbb8 chore: format 2026-05-09 21:07:08 +09:00
Timothy Jaeryang Baek df42d96c95 refac 2026-05-09 21:05:49 +09:00
Timothy Jaeryang Baek 2fa3b84241 chore: bump 2026-05-09 21:04:52 +09:00
Classic298 8854541508 fix: prevent redirect-based SSRF in web-fetch and image-load call sites (#24491)
validate_url() in retrieval/web/utils.py only validates the initial URL.
The HTTP clients used downstream (sync requests, sync requests via the
parent WebBaseLoader._scrape, aiohttp via load_url_image) followed 3xx
redirects by default and did not re-validate the redirect target against
the private-IP / metadata-IP block list. An authenticated user could
submit a public URL that 302-redirected to an internal address (RFC1918,
127.0.0.1, 169.254.169.254, etc.) and the redirected response was returned
to them, enabling SSRF reads of internal services and cloud metadata.

Three call sites needed allow_redirects=False to match the policy already
enforced on the async _fetch() path:

- SafeWebBaseLoader: override requests_kwargs in __init__ so that the
  inherited synchronous _scrape() path passes allow_redirects=False to
  self.session.get() (the parent WebBaseLoader uses requests' default
  allow_redirects=True).
- get_content_from_url (retrieval/utils.py): pass allow_redirects=False
  on the streamed requests.get(...) call.
- load_url_image (routers/images.py, image-edits endpoint): pass
  allow_redirects=False on the aiohttp session.get(...) call.

Reports consolidated under GHSA-rh5x-h6pp-cjj6:
- GHSA-rh5x-h6pp-cjj6 (tenbbughunters / Tenable) - sync _scrape
- GHSA-5vxg-6gmv-m2qr (YLChen-007) - load_url_image
- GHSA-hf76-c83f-63w2 (tempcollab) - aiohttp _fetch (already fixed)
- GHSA-h55f-h5fh-mvm4 (sneaXOR) - get_content_from_url
2026-05-09 21:01:45 +09:00
Timothy Jaeryang Baek 793e628ac3 refac 2026-05-09 20:59:29 +09:00
Classic298 a0268e51fc Merge pull request #24486 from Classic298/fix/notes-is-pinned-typeerror
fix: notes is_pinned TypeError on create/get
2026-05-09 20:56:06 +09:00
Timothy Jaeryang Baek 0f07af1bb8 fix: bump to 0.9.4, changelog for scroll fix 2026-05-09 16:41:01 +09:00
Timothy Jaeryang Baek 7d3efb8513 refac 2026-05-09 16:37:19 +09:00
485 changed files with 72775 additions and 63466 deletions
+10 -1
View File
@@ -19,4 +19,13 @@ FORWARDED_ALLOW_IPS='*'
# DO NOT TRACK
SCARF_NO_ANALYTICS=true
DO_NOT_TRACK=true
ANONYMIZED_TELEMETRY=false
ANONYMIZED_TELEMETRY=false
# Valkey Vector Store (requires VECTOR_DB=valkey)
# VALKEY_URL='valkey://localhost:6379'
# VALKEY_COLLECTION_PREFIX='open_webui'
# VALKEY_INDEX_TYPE='HNSW'
# VALKEY_DISTANCE_METRIC='COSINE'
# VALKEY_HNSW_M='16'
# VALKEY_HNSW_EF_CONSTRUCTION='200'
# VALKEY_HNSW_EF_RUNTIME='10'
+30 -36
View File
@@ -1,82 +1,76 @@
name: Feature Request
description: Suggest an idea for this project
description: Suggest a new feature or improvement
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
## Important Notes
### Before submitting
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
It's likely we're already tracking it! If youre unsure, start a discussion post first.
## Before Submitting
#### Scope
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
This will help us efficiently focus on improving the project.
### Collaborate respectfully
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
### Scope Guidelines
Remember:
- Open WebUI is a **volunteer-driven project**
- It's managed by a **single maintainer**
- It's supported by contributors who also have **full-time jobs**
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
We appreciate your time and ask that you **respect ours**.
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
### Be Respectful
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
### Contributing
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
### Bug reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, its not that the issue doesnt exist; we need your help!
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
### Reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
- type: checkboxes
id: existing-issue
attributes:
label: Check Existing Issues
description: Please confirm that you've checked for existing similar requests
description: Confirm you have searched for similar requests.
options:
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
required: true
- type: checkboxes
id: feature-scope
attributes:
label: Verify Feature Scope
description: Please confirm the feature's scope is within the described scope
description: Confirm this request belongs in Issues rather than Discussions.
options:
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
description: Is this related to a problem? Describe the pain point clearly.
placeholder: "e.g., I'm frustrated when..."
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Desired Solution you'd like
description: Clearly describe what you want to happen.
label: Proposed Solution
description: Describe what you would like to happen.
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
description: Describe any alternative solutions or workarounds you have considered.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
description: Add any other context, mockups, or screenshots about the feature request.
+10 -5
View File
@@ -4,17 +4,22 @@ updates:
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: pip
directory: '/backend'
schedule:
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: 'github-actions'
- package-ecosystem: github-actions
directory: '/'
schedule:
# Check for updates to GitHub Actions every week
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: dev
+34 -34
View File
@@ -18,75 +18,75 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
- **chore**: Refactor, cleanup, or other non-functional code changes
- **docs**: Documentation update or addition
- **feat**: Introduces a new feature or enhancement to the codebase
- **fix**: Bug fix or error correction
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
- **BREAKING CHANGE**: Changes affecting backward compatibility
- **build**: Build system or dependency changes
- **ci**: CI/CD workflow changes
- **chore**: Refactoring, cleanup, or non-functional changes
- **docs**: Documentation additions or updates
- **feat**: New features or enhancements
- **fix**: Bug fixes or corrections
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
- **perf**: Performance improvements
- **refactor**: Code restructuring
- **style**: Formatting changes (whitespace, semicolons, etc.)
- **test**: Test additions or corrections
- **WIP**: Work in progress
# Changelog Entry
### Description
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
- [Describe the changes, including motivation and impact]
### Added
- [List any new features, functionalities, or additions]
- [New features, functionalities, or additions]
### Changed
- [List any changes, updates, refactorings, or optimizations]
- [Changes, updates, refactorings, or optimizations]
### Deprecated
- [List any deprecated functionality or features that have been removed]
- [Deprecated functionality or features]
### Removed
- [List any removed features, files, or functionalities]
- [Removed features, files, or functionalities]
### Fixed
- [List any fixes, corrections, or bug fixes]
- [Bug fixes or corrections]
### Security
- [List any new or updated security-related changes, including vulnerability fixes]
- [Security-related changes or vulnerability fixes]
### Breaking Changes
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
---
### Additional Information
- [Insert any additional context, notes, or explanations for the changes]
- [Reference any related issues, commits, or other relevant information]
- [Any additional context, notes, or references to related issues/commits]
### Screenshots or Videos
- [Attach any relevant screenshots or videos demonstrating the changes]
- [Attach relevant screenshots or videos demonstrating the changes]
### Contributor License Agreement
+40
View File
@@ -0,0 +1,40 @@
# ─────────────────────────────────────────────────────────────────────────────
# Backend CI — Python formatting checks via Ruff
# Runs on pushes and PRs to main/dev when backend files change
# ─────────────────────────────────────────────────────────────────────────────
name: Python CI
on:
push:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
pull_request:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
concurrency:
group: backend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Ruff format check across supported Python versions ───────────────────
format-check:
name: Ruff Format (${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install formatter
run: pip install "ruff>=0.15.5"
- name: Verify formatting
run: ruff format --check . --exclude .venv --exclude venv
-61
View File
@@ -1,61 +0,0 @@
name: Release
on:
push:
branches:
- main # or whatever branch you want to use
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
git diff --cached --diff-filter=d package.json || {
echo "No changes to package.json"
exit 1
}
- name: Get version number from package.json
id: get_version
run: |
VERSION=$(jq -r '.version' package.json)
echo "::set-output name=version::$VERSION"
- name: Extract latest CHANGELOG entry
run: |
VERSION="${{ steps.get_version.outputs.version }}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.get_version.outputs.version }}" \
--title "v${{ steps.get_version.outputs.version }}" \
--notes-file /tmp/release-notes.md
- name: Upload package to GitHub release
uses: actions/upload-artifact@v4
with:
name: package
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build workflow
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker-build.yaml',
ref: 'v${{ steps.get_version.outputs.version }}',
})
-917
View File
@@ -1,917 +0,0 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
env:
REGISTRY: ghcr.io
jobs:
build-main-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.platform }}-
latest=false
- name: Build Docker image (latest)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-main-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda126-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda126 tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda126-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda126)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
USE_CUDA_VER=cu126
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-ollama-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-ollama-${{ matrix.platform }}-
latest=false
- name: Build Docker image (ollama)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_OLLAMA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-ollama-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-slim-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-slim-${{ matrix.platform }}-
latest=false
- name: Build Docker image (slim)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-slim-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda-images:
runs-on: ubuntu-latest
needs: [build-cuda-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda126-images:
runs-on: ubuntu-latest
needs: [build-cuda126-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-ollama-images:
runs-on: ubuntu-latest
needs: [build-ollama-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-slim-images:
runs-on: ubuntu-latest
needs: [build-slim-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-slim-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
+331
View File
@@ -0,0 +1,331 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ${{ matrix.platform.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- arch: linux/amd64
runner: ubuntu-latest
- arch: linux/arm64
runner: ubuntu-24.04-arm
variant:
- name: main
suffix: ""
build_args: ""
free_disk: false
- name: cuda
suffix: "-cuda"
build_args: "USE_CUDA=true"
free_disk: true
- name: cuda126
suffix: "-cuda126"
build_args: |
USE_CUDA=true
USE_CUDA_VER=cu126
free_disk: true
- name: ollama
suffix: "-ollama"
build_args: "USE_OLLAMA=true"
free_disk: false
- name: slim
suffix: "-slim"
build_args: "USE_SLIM=true"
free_disk: false
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
platform=${{ matrix.platform.arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV}
- name: Free disk space
if: matrix.variant.free_disk
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}-
latest=false
- name: Build Docker image
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform.arch }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
${{ matrix.variant.build_args }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.variant.name }}-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [build]
if: ${{ !cancelled() }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
variant:
- name: main
suffix: ""
- name: cuda
suffix: "-cuda"
- name: cuda126
suffix: "-cuda126"
- name: ollama
suffix: "-ollama"
- name: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Download digests
id: download
uses: actions/download-artifact@v5
with:
pattern: digests-${{ matrix.variant.name }}-*
path: /tmp/digests
merge-multiple: true
continue-on-error: true
- name: Check digests
id: check
run: |
count=$(find /tmp/digests -type f 2>/dev/null | wc -l | tr -d ' ')
echo "digest_count=$count" >> $GITHUB_OUTPUT
if [ "$count" -lt 2 ]; then
echo "::warning::${{ matrix.variant.name }}: found $count digest(s), need 2 (one per arch). Skipping merge."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
if: steps.check.outputs.skip != 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
if: steps.check.outputs.skip != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
if: steps.check.outputs.skip != 'true'
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Create manifest list and push
if: steps.check.outputs.skip != 'true'
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
if: steps.check.outputs.skip != 'true'
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
copy-to-dockerhub:
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
needs: [merge]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
-46
View File
@@ -1,46 +0,0 @@
name: Python CI
on:
push:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format Backend'
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- 3.11.x
- 3.12.x
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '${{ matrix.python-version }}'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "ruff>=0.15.5"
- name: Ruff format check
run: ruff format --check . --exclude .venv --exclude venv
@@ -1,65 +0,0 @@
name: Frontend Build
on:
push:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format & Build Frontend'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm install --force
- name: Format Frontend
run: npm run format
- name: Run i18next
run: npm run i18n:parse
- name: Check for Changes After Format
run: git diff --exit-code
- name: Build Frontend
run: npm run build
test-frontend:
name: 'Frontend Unit Tests'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm ci --force
- name: Run vitest
run: npm run test:frontend
+63
View File
@@ -0,0 +1,63 @@
# ─────────────────────────────────────────────────────────────────────────────
# Frontend CI — Lint, format check, build, and unit tests
# Runs on pushes and PRs to main/dev, skipping backend-only changes
# ─────────────────────────────────────────────────────────────────────────────
name: Frontend Build
on:
push:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
pull_request:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
concurrency:
group: frontend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Format, i18n, and production build ────────────────────────────────────
format-and-build:
name: Format & Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies
run: npm install --force
- name: Verify code formatting
run: npm run format
- name: Verify i18n strings
run: npm run i18n:parse
- name: Ensure working tree is clean
run: git diff --exit-code
- name: Production build
run: npm run build
# ── Vitest unit tests ────────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies (frozen lockfile)
run: npm ci --force
- name: Execute test suite
run: npm run test:frontend
-255
View File
@@ -1,255 +0,0 @@
name: Integration Test
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
cypress-run:
name: Run Cypress Integration Tests
runs-on: ubuntu-latest
steps:
- name: Maximize build space
uses: AdityaGarg8/remove-unwanted-software@v4.1
with:
remove-android: 'true'
remove-haskell: 'true'
remove-codeql: 'true'
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build and run Compose Stack
run: |
docker compose \
--file docker-compose.yaml \
--file docker-compose.api.yaml \
--file docker-compose.a1111-test.yaml \
up --detach --build
- name: Delete Docker build cache
run: |
docker builder prune --all --force
- name: Wait for Ollama to be up
timeout-minutes: 5
run: |
until curl --output /dev/null --silent --fail http://localhost:11434; do
printf '.'
sleep 1
done
echo "Service is up!"
- name: Preload Ollama model
run: |
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
- name: Cypress run
uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with:
browser: chrome
wait-on: 'http://localhost:3000'
config: baseUrl=http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
name: Upload Cypress videos
with:
name: cypress-videos
path: cypress/videos
if-no-files-found: ignore
- name: Extract Compose logs
if: always()
run: |
docker compose logs > compose-logs.txt
- uses: actions/upload-artifact@v4
if: always()
name: Upload Compose logs
with:
name: compose-logs
path: compose-logs.txt
if-no-files-found: ignore
# pytest:
# name: Run Backend Tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install -r backend/requirements.txt
# - name: pytest run
# run: |
# ls -al
# cd backend
# PYTHONPATH=. pytest . -o log_cli=true -o log_cli_level=INFO
migration_test:
name: Run Migration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
# mysql:
# image: mysql
# env:
# MYSQL_ROOT_PASSWORD: mysql
# MYSQL_DATABASE: mysql
# options: >-
# --health-cmd "mysqladmin ping -h localhost"
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# ports:
# - 3306:3306
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: yezz123/setup-uv@v4
with:
uv-venv: venv
- name: Activate virtualenv
run: |
. venv/bin/activate
echo PATH=$PATH >> $GITHUB_ENV
- name: Install dependencies
run: |
uv pip install -r backend/requirements.txt
- name: Test backend with SQLite
id: sqlite
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
run: |
cd backend
uvicorn open_webui.main:app --port "8080" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 40 seconds for the server to start
for i in {1..40}; do
curl -s http://localhost:8080/api/config > /dev/null && break
sleep 1
if [ $i -eq 40 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
- name: Test backend with Postgres
if: success() || steps.sqlite.conclusion == 'failure'
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DATABASE_POOL_SIZE: 10
DATABASE_POOL_MAX_OVERFLOW: 10
DATABASE_POOL_TIMEOUT: 30
run: |
cd backend
uvicorn open_webui.main:app --port "8081" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 20 seconds for the server to start
for i in {1..20}; do
curl -s http://localhost:8081/api/config > /dev/null && break
sleep 1
if [ $i -eq 20 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
# Check that service will reconnect to postgres when connection will be closed
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has failed before postgres reconnect check"
exit 1
fi
echo "Terminating all connections to postgres..."
python -c "import os, psycopg2 as pg2; \
conn = pg2.connect(dsn=os.environ['DATABASE_URL'].replace('+pool', '')); \
cur = conn.cursor(); \
cur.execute('SELECT pg_terminate_backend(psa.pid) FROM pg_stat_activity psa WHERE datname = current_database() AND pid <> pg_backend_pid();')"
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has not reconnected to postgres after connection was closed: returned status $status_code"
exit 1
fi
# - name: Test backend with MySQL
# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
# env:
# WEBUI_SECRET_KEY: secret-key
# GLOBAL_LOG_LEVEL: debug
# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
# run: |
# cd backend
# uvicorn open_webui.main:app --port "8083" --forwarded-allow-ips '*' &
# UVICORN_PID=$!
# # Wait up to 20 seconds for the server to start
# for i in {1..20}; do
# curl -s http://localhost:8083/api/config > /dev/null && break
# sleep 1
# if [ $i -eq 20 ]; then
# echo "Server failed to start"
# kill -9 $UVICORN_PID
# exit 1
# fi
# done
# # Check that the server is still running after 5 seconds
# sleep 5
# if ! kill -0 $UVICORN_PID; then
# echo "Server has stopped"
# exit 1
# fi
+74
View File
@@ -0,0 +1,74 @@
# ─────────────────────────────────────────────────────────────────────────────
# Release — Create GitHub release from CHANGELOG, trigger Docker builds
# Runs on pushes to main when package.json version changes
# ─────────────────────────────────────────────────────────────────────────────
name: Release
on:
push:
branches: [main]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Create release and trigger downstream workflows ──────────────────────
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v5
- name: Abort if package.json unchanged
run: |
git diff --cached --diff-filter=d package.json || {
echo "package.json not modified — skipping release"
exit 1
}
- name: Read version
id: pkg
run: echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: Extract release notes from CHANGELOG
run: |
VER="${{ steps.pkg.outputs.version }}"
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
CHANGELOG.md > /tmp/release-notes.md
- name: Publish GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
echo "Release v${{ steps.pkg.outputs.version }} already exists — skipping creation"
else
gh release create "v${{ steps.pkg.outputs.version }}" \
--title "v${{ steps.pkg.outputs.version }}" \
--notes-file /tmp/release-notes.md
fi
- name: Archive source
uses: actions/upload-artifact@v4
with:
name: release-archive
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker.yaml',
ref: 'v${{ steps.pkg.outputs.version }}',
})
+1
View File
@@ -310,3 +310,4 @@ dist
cypress/videos
cypress/screenshots
.vscode/settings.json
.cptr
+176
View File
@@ -5,6 +5,182 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.6] - 2026-06-01
### Added
- 📦 **Official knowledge base sync tool.** A new companion tool from Open WebUI, oikb, keeps a knowledge base in sync with a local directory, GitHub repo, S3 bucket, Confluence space, or any of more than 40 other sources, uploading only new and changed files using the incremental sync support added in this release. [oikb](https://github.com/open-webui/oikb)
- 📂 **Smart directory sync for knowledge bases.** Local directories can now be synced into a knowledge base in one action: file checksums are compared against what's already stored, and only added or modified files are uploaded while removed files and orphaned subdirectories are cleaned up, with the directory structure mirrored automatically and per-file progress shown throughout. [#19190](https://github.com/open-webui/open-webui/issues/19190), [#19394](https://github.com/open-webui/open-webui/issues/19394), [Commit](https://github.com/open-webui/open-webui/commit/60c9db1cb81d021589cb49bee8744a799b51211f), [Commit](https://github.com/open-webui/open-webui/commit/73bdf86766d3f44467cdec436786fe089481baf3), [Commit](https://github.com/open-webui/open-webui/commit/9835b3f1dd7aa9c92ff08a634a0f97cc2a046e42), [Commit](https://github.com/open-webui/open-webui/commit/97252fa609440573251d8e75090f347ed1e51e1d), [Commit](https://github.com/open-webui/open-webui/commit/8f2d346e10c47b57bf6b5a6aa02d453488a88b89), [Commit](https://github.com/open-webui/open-webui/commit/1527eb6e01d441225c979d12d78b7625edf1086f)
- 🗂️ **Knowledge base folders.** Files inside a knowledge base can now be organized into nested folders, with breadcrumb navigation that makes it much easier to manage and find content in large collections. [Commit](https://github.com/open-webui/open-webui/commit/c2cbc47ca76ebfa21e1d36279dbd859283cbbfb1), [Commit](https://github.com/open-webui/open-webui/commit/ab0ee858b73738c5b77d1d455e66e66bc61df1c4), [Commit](https://github.com/open-webui/open-webui/commit/2ad327a4dce64a8eedf5eb41c73b8520344fdd82), [Commit](https://github.com/open-webui/open-webui/commit/171150c1e12b5713f77a5ea0dd19c6581c7bef2e), [Commit](https://github.com/open-webui/open-webui/commit/e7d2ddbb1d14c03a52797751d24a98132aac0cd8), [Commit](https://github.com/open-webui/open-webui/commit/32a417bbf66ed28a5ca8a759ad3a87b2a2aa358a)
- 🧰 **Filesystem tool for knowledge bases.** A new built-in tool, enabled via the "ENABLE_KB_EXEC" environment variable, lets AI models browse and search knowledge base contents using familiar filesystem commands such as 'ls', 'cat', 'grep', 'find', 'head', 'tail', and 'sed', including pipes between them. [Commit](https://github.com/open-webui/open-webui/commit/5b125c24d4eae925d3287efa626595bab29d5c33), [Commit](https://github.com/open-webui/open-webui/commit/ecec86dd32aa396dd5a383e9be5b0f16c4346c48), [Commit](https://github.com/open-webui/open-webui/commit/9ef579ce4be5b82f9281b085a621fed5b04e0d26), [Commit](https://github.com/open-webui/open-webui/commit/2f642754ac6b8c430228a7fc289357a6c6652235), [Commit](https://github.com/open-webui/open-webui/commit/3b00e5721a60518ea317c4fd61a6d0d182961a2f), [Commit](https://github.com/open-webui/open-webui/commit/4e78b355efe0611f27fddef591c69f7c1259a9f6), [Commit](https://github.com/open-webui/open-webui/commit/74f95a9b0d6b89b241701422170d54e21e701baa), [Commit](https://github.com/open-webui/open-webui/commit/cc16e06c32de48bdf464db5814483a49f3527b63), [Commit](https://github.com/open-webui/open-webui/commit/1ea54c3217f487990b6a4c0f0d8ea675a479b6e1)
- ✏️ **File renaming in knowledge bases.** Files inside a knowledge base can now be renamed directly from the workspace, with the new name reflected wherever the file is referenced. [Commit](https://github.com/open-webui/open-webui/commit/3127f1b46255626ea92eb0b598e45078287303f1)
- 😀 **Emoji picker in message input.** A new emoji button in the rich text formatting toolbar lets you browse and insert emojis directly into your messages. [#24704](https://github.com/open-webui/open-webui/pull/24704)
- 🪄 **Per-chat skills toggle.** Skills can now be turned on or off for a conversation directly from the chat Integrations menu, the same way tools and capabilities already work, instead of only through the model preset. [#25036](https://github.com/open-webui/open-webui/issues/25036), [#25037](https://github.com/open-webui/open-webui/pull/25037)
- 🔎 **Access preview for users and groups.** Administrators can now preview exactly which models, knowledge bases, and tools a given user or group can access, making it easier to audit and verify permission setups. [Commit](https://github.com/open-webui/open-webui/commit/9c14740ffb009d550dcbd5d6c599dac57053f112)
- 📄 **Configurable knowledge base file page size.** Administrators can now request a larger page size when listing a knowledge base's files through the API, reducing the number of requests needed to retrieve large collections instead of paging through fixed increments of 30. [#25148](https://github.com/open-webui/open-webui/issues/25148), [Commit](https://github.com/open-webui/open-webui/commit/a4d1b3e9378a61a11dd9822a8dbb525f39753081)
- 🔃 **Persistent processing indicator for knowledge files.** Files still being processed in a knowledge base now keep showing a processing indicator across page reloads, so you can tell what's still ingesting after navigating away and back. [#25031](https://github.com/open-webui/open-webui/issues/25031), [Commit](https://github.com/open-webui/open-webui/commit/ad9f2eeb15a448147d5e47e6a391cabb9aefd0ea)
- 📑 **MinerU file type configuration.** Administrators can now configure which file types are processed by the MinerU document loader, via the new "MINERU_FILE_EXTENSIONS" setting, extending it beyond PDF to formats like DOCX, PPTX, and XLSX. [Commit](https://github.com/open-webui/open-webui/commit/d4030a8aa5d48c2a1cb06c461566844aca2530ab)
- 📃 **Legacy Word document support.** Older ".doc" Word files can now have their text extracted by the default document extraction engine, in addition to the modern ".docx" format. [Commit](https://github.com/open-webui/open-webui/commit/9e3e24e304f8ff210494380b46f614a2984cafb3)
- 📁 **Create subfolders from the folder header.** Chat folders can now have subfolders created directly from the folder header in the chat view, not just from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/1f0948bcbef2af73b155535ea27762c522260afc)
- ⚡ **Faster initial page loads.** The configuration endpoint that loads on every page visit no longer runs an unnecessary user-count query, making the initial application load lighter on the database, especially on instances with many users. [Commit](https://github.com/open-webui/open-webui/commit/0adc090dcbe636c9c9645d8ec7f6b89ea514870b)
- 🚀 **Faster tool-enabled chat completions.** Chat completions that use multiple tools now start faster because the tools they reference are fetched from the database in a single batch query instead of one query per tool. [#24808](https://github.com/open-webui/open-webui/pull/24808), [Commit](https://github.com/open-webui/open-webui/commit/cc94a90b4d4d690bc7cb9f7124f2d6e552973970)
- 🏎️ **More responsive web search under load.** Web search through SearXNG, Google PSE, Brave, Serper, and Serpstack now uses non-blocking network calls, so the server stays responsive to other users while a search is in flight, and concurrent multi-query searches complete faster. [Commit](https://github.com/open-webui/open-webui/commit/b94245d2ee191e8ef118bf9de1ff7539503bfec9)
- 🐎 **Lighter Ollama backend connections.** Requests to Ollama backends now reuse a shared connection pool instead of opening a fresh session each time, reducing TCP and TLS handshake overhead for installs that poll Ollama frequently or have multiple backends configured. [Commit](https://github.com/open-webui/open-webui/commit/5d9a09a88a9094ebfcd249340be3aaee544b34d0)
- 💽 **Fewer redundant model-list writes.** On multi-instance deployments backed by Redis, the model list is no longer rewritten when it hasn't changed, cutting a major source of redundant writes. [#25469](https://github.com/open-webui/open-webui/issues/25469), [#25474](https://github.com/open-webui/open-webui/pull/25474), [Commit](https://github.com/open-webui/open-webui/commit/fd76b51ab2ad4c4192f2c98153e47888712c2009)
- 📉 **Faster websocket disconnect cleanup.** Disconnecting from a collaborative session no longer triggers a scan across the entire Redis keyspace, using a per-session index instead, which keeps disconnects cheap on large deployments. [#25466](https://github.com/open-webui/open-webui/issues/25466), [Commit](https://github.com/open-webui/open-webui/commit/c7de057a4a54cc80f366ad949417e78fae756d4c)
- 📝 **Frontmatter auto-fill for tools, functions, and skills.** Opening a tool, function, or skill editor now auto-fills the name, id, and description fields from the file's frontmatter, saving you from re-entering metadata already declared in the source. [#24649](https://github.com/open-webui/open-webui/pull/24649), [Commit](https://github.com/open-webui/open-webui/commit/ef975649b26d3e7cd589c49be2fc77cee80fad8f)
- 🪪 **More user placeholders in custom headers.** Custom-header templates for direct connections and tool servers now support "{{USER_EMAIL}}" and "{{USER_ROLE}}" alongside the existing user and session placeholders. [Commit](https://github.com/open-webui/open-webui/commit/ed73ef3d8df988b0e9646b82df5b1a453202ef8d)
- ⏱️ **Configurable MCP connection timeout.** The timeout for the initial handshake with an MCP tool server is now configurable via the new "MCP_INITIALIZE_TIMEOUT" setting, so servers that are slow to start or expose many tools can finish connecting instead of timing out. [#25011](https://github.com/open-webui/open-webui/pull/25011), [Commit](https://github.com/open-webui/open-webui/commit/4297c02b121180e239a61c483ac8477cc557d4ef)
- 📐 **Profile image size limit.** Administrators can now cap the size of inline profile images via the new "PROFILE_IMAGE_MAX_DATA_URI_SIZE" setting, bounding how much database and cache space inline avatars and model icons can consume. [#25468](https://github.com/open-webui/open-webui/issues/25468), [#25476](https://github.com/open-webui/open-webui/pull/25476)
- 🎫 **Wildcard OAuth role mapping.** Administrators can now set "\*" in the allowed OAuth roles to grant the user role to any authenticated OAuth user, instead of having to enumerate every accepted role. [#25062](https://github.com/open-webui/open-webui/pull/25062), [Commit](https://github.com/open-webui/open-webui/commit/07cbc91a8eba3a9a3b39588b2ae5de916930af70)
- 📊 **Paginated feedback history.** The feedback and evaluation history list is now paginated, keeping it responsive for instances that have accumulated large numbers of feedback entries. [Commit](https://github.com/open-webui/open-webui/commit/160a6694e4bd66fc42e6361947516e8e15414ef3)
- 🔘 **Bulk enable or disable automations.** Automations can now be enabled or disabled in bulk from an actions menu on the automations page, instead of toggling each one individually. [Commit](https://github.com/open-webui/open-webui/commit/675e9bee5af8b9fb390fcb235a6c2c4766e78c75)
- ➡️ **Optional auto-redirect to single sign-on.** Administrators can now enable "OAUTH_AUTO_REDIRECT" so that, on deployments with a single sign-on provider and no other login methods, users are sent straight to the provider instead of seeing a login page first. [#25067](https://github.com/open-webui/open-webui/pull/25067), [Commit](https://github.com/open-webui/open-webui/commit/d64ef1803d2f5cedb7b3a308151d08ff2cd2b8e1)
- ☁️ **Azure AI Foundry v1 with Entra ID.** Open WebUI now supports Azure AI Foundry's OpenAI v1 endpoint together with Microsoft Entra ID authentication, so these connections work without manual workarounds. [#24761](https://github.com/open-webui/open-webui/issues/24761), [#24985](https://github.com/open-webui/open-webui/pull/24985), [Commit](https://github.com/open-webui/open-webui/commit/eb4eebc3ce1042cb0d393bf890c1895db6e08b19)
- 🌎 **Linkup web search provider.** Administrators can now select Linkup as the web search provider from the admin settings, with options to configure the API key and search depth. [#24752](https://github.com/open-webui/open-webui/pull/24752), [Commit](https://github.com/open-webui/open-webui/commit/56c0d00e13c74d665124ec9f1cae78e83ca60b6a)
- 🧊 **Valkey vector database support.** Valkey can now be used as the vector database backend, configurable through new "VALKEY_URL" and related settings including index type, distance metric, and HNSW tuning. [#24769](https://github.com/open-webui/open-webui/pull/24769), [Commit](https://github.com/open-webui/open-webui/commit/c0f1aa291938bbef62db8d4013dbc498b0abea15)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Spanish (Spain), Swedish, German, Korean, Catalan, Russian, Irish, Simplified Chinese, Traditional Chinese, Finnish, Polish, Turkish, and Malay were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🛡️ **Tool server permission enforcement.** The per-user permission for inline tool servers is now enforced on chat-completion requests, so users without that permission can no longer bypass the admin setting by supplying tool servers directly in their requests. [Commit](https://github.com/open-webui/open-webui/commit/5cc1eb517094e3507915664817285f1e6e37a16d)
- 🔒 **Knowledge base access check in search tool.** The built-in knowledge search tool now verifies that the caller can access a knowledge base before searching it by id, preventing users from reading the contents of knowledge bases they have not been granted access to. [#25113](https://github.com/open-webui/open-webui/pull/25113)
- 🗄️ **Cross-user access to retrieval collections.** Resolving the documents used for retrieval now verifies the caller's access to each referenced file and rejects client-supplied collection names, preventing a crafted request from pulling another user's files or vector collections into its context. [Commit](https://github.com/open-webui/open-webui/commit/ee47c9c833f8889f3abe99d2266c56e8f8d40230)
- 🔣 **Collection name validation.** Vector collection names are now rejected unless they contain only safe characters, preventing malformed names from reaching the vector store or breaking out of a database query expression. [#24982](https://github.com/open-webui/open-webui/pull/24982)
- 🚫 **Unscoped retrieval collections denied by default.** Retrieval requests for collection names that don't correspond to a known file, memory, web-search, or knowledge base are now denied for non-admins by default, with a new "ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS" setting to restore the previous behavior if needed. [Commit](https://github.com/open-webui/open-webui/commit/c93f071700520a60833b7f9616c292b11f210880)
- 📜 **Prompt history authorization.** Comparing, deleting, and restoring prompt versions now verify the history entry belongs to the prompt you're authorized for, preventing access to or modification of another prompt's version history. [#25056](https://github.com/open-webui/open-webui/pull/25056)
- 🚦 **Code interpreter permission on the legacy path.** The legacy code-execution path now enforces the same permission and capability checks as the current one, so users without the code interpreter permission can no longer trigger code execution through it. [#24724](https://github.com/open-webui/open-webui/pull/24724)
- 🧱 **API key endpoint restriction bypass.** The endpoint allow-list that limits which paths an API key may reach is now matched against the routed request path directly, preventing a crafted request from slipping past the restriction. [#25123](https://github.com/open-webui/open-webui/pull/25123)
- 🚧 **System prompt bypass via request parameter.** The flag that skips a model's configured system prompt can no longer be set by external clients through a request parameter, so admin-configured system prompts can't be bypassed from the API. [#25156](https://github.com/open-webui/open-webui/pull/25156)
- 🚪 **Terminal proxy path traversal.** The terminal proxy now fully decodes request paths before validating them, blocking multi-encoded payloads that could otherwise escape the intended path. [#25157](https://github.com/open-webui/open-webui/pull/25157)
- 🪤 **Cache file path traversal.** The cache file server now requires an exact directory boundary match, closing a gap where a sibling directory whose name began with the cache directory's name could be used to serve files from outside it. [#25086](https://github.com/open-webui/open-webui/pull/25086)
- 🔀 **Ollama backend selection access check.** Requests can no longer target an arbitrary Ollama backend by index; a caller-supplied backend selector is now verified against the backends that actually serve the requested model. [Commit](https://github.com/open-webui/open-webui/commit/7139797be04030b9d1016782f1dbb251c5fe68bb)
- 🔓 **Cross-user file exfiltration via image URLs.** When a chat message references a file by id in an "image_url" field, the server now resolves that file only for its owner, an administrator, or a user with an explicit read grant, preventing other authenticated users from extracting a file's contents by routing it through the model. [#24625](https://github.com/open-webui/open-webui/pull/24625), [Commit](https://github.com/open-webui/open-webui/commit/c75fe8e74b72617c51282cc3ea0a2e8d9cdd9140)
- 📌 **Chat file attachment access checks.** Attaching files to a chat now links only files the caller can read, preventing a user from associating another user's file with their chat to access its contents. [#25054](https://github.com/open-webui/open-webui/pull/25054)
- 🧾 **Model knowledge file ownership checks.** Creating or updating a model now verifies that any knowledge files attached to it are files the editor can access, preventing another user's files from being attached to a model. [#25055](https://github.com/open-webui/open-webui/pull/25055), [Commit](https://github.com/open-webui/open-webui/commit/27fb20c13a4bf8501f7a485abe0654eb5880980d)
- 📅 **Calendar event move authorization.** Updating a calendar event to move it into a different calendar now requires write access on the destination calendar, preventing users from injecting events into calendars they cannot write to. [#24764](https://github.com/open-webui/open-webui/pull/24764)
- 📣 **Channel chat access control.** Generating a response in a channel context now verifies the caller's access to that channel and scopes the included messages, preventing access to channels or messages the user isn't permitted to see. [#24725](https://github.com/open-webui/open-webui/pull/24725)
- 🕸️ **Web loader SSRF gating with Playwright.** When the Playwright-based web loader is in use, page navigations and redirects are now validated the same way as the default loader, closing a gap where the Playwright path could reach internal or otherwise blocked URLs. [#24756](https://github.com/open-webui/open-webui/pull/24756)
- 🛂 **DNS rebinding protection for URL fetches.** The IP address validated for an outbound URL fetch is now the same one used for the actual connection, closing a DNS rebinding window where an attacker-controlled hostname could resolve to a public IP during the safety check and then to a private IP when the connection was opened. [#24759](https://github.com/open-webui/open-webui/pull/24759)
- 🪞 **OAuth profile picture redirect handling.** The OAuth profile picture fetch now follows redirects only when administrators have explicitly allowed it, closing a window where a redirect from an externally validated URL could be used to reach internal addresses. [#24809](https://github.com/open-webui/open-webui/pull/24809)
- 🧼 **Model profile image script injection.** Model profile images are now validated on save and only served inline when they are a known-safe image type, preventing a crafted SVG profile image from running scripts in other users' browsers, while existing legacy images that fail validation are cleared gracefully instead of breaking the model list. [#25060](https://github.com/open-webui/open-webui/pull/25060), [#25173](https://github.com/open-webui/open-webui/pull/25173)
- 🧯 **Diagram rendering script injection.** Mermaid diagrams rendered in chat are now sanitized before display, preventing a crafted diagram from running scripts in the viewer's browser. [#25219](https://github.com/open-webui/open-webui/pull/25219)
- 🔐 **Shared-chat file write protection.** Access to a file through a shared chat now only grants read access, so users who can read a shared chat can no longer modify or delete files attached to it. [#24755](https://github.com/open-webui/open-webui/pull/24755)
- 🔏 **Cross-origin embed prompt control.** When Open WebUI is embedded in an iframe on a different origin, the embedding page can now only drive the chat input or submit prompts if the user has explicitly opted in via the "iframe Sandbox Allow Same Origin" setting, preventing untrusted host pages from triggering confirmation dialogs or controlling the chat. [#24767](https://github.com/open-webui/open-webui/pull/24767), [Commit](https://github.com/open-webui/open-webui/commit/eb3076c1b02d90c2ce6e6d3beb08a37987c740ec)
- 🗂️ **Chat folder ownership checks.** Creating a chat or updating a chat's folder now verifies the referenced folder belongs to the current user, preventing chats from being associated with folders owned by other people. [#24588](https://github.com/open-webui/open-webui/pull/24588)
- 🧩 **Chat recovery from corrupted history.** Chats whose internal message graph was left in a malformed state by a failed regeneration now open and load correctly, with missing roles, parent references, and current-message pointers reconstructed automatically instead of breaking the chat. [#24424](https://github.com/open-webui/open-webui/issues/24424), [#24157](https://github.com/open-webui/open-webui/issues/24157), [#20474](https://github.com/open-webui/open-webui/issues/20474), [#24799](https://github.com/open-webui/open-webui/pull/24799), [Commit](https://github.com/open-webui/open-webui/commit/d310a0777c4c48ec772bdc9a510005d5e91b09c7)
- 📨 **Imported chats with folders appear correctly.** Importing grouped chats no longer leaves them invisible when a referenced folder is missing; such chats now appear in the chat list instead of being silently orphaned. [#24910](https://github.com/open-webui/open-webui/issues/24910), [Commit](https://github.com/open-webui/open-webui/commit/7f7cd210186cb6a67e028c17974eb210fc7ba9fd)
- 🎟️ **MCP tool server sessions stay connected.** OAuth-authenticated MCP tool server sessions are no longer mistakenly refreshed and deleted by the single sign-on session handler, so those connections stay active. [#24618](https://github.com/open-webui/open-webui/issues/24618), [Commit](https://github.com/open-webui/open-webui/commit/c8eb8edca4174ec68fabd071d6b08c0bc07f8117)
- 🤝 **MCP OAuth scope discovery.** The OAuth flow for MCP tool servers now reads the scopes a server advertises through its Protected Resource Metadata, so connecting to servers that declare their own scopes succeeds. [#24730](https://github.com/open-webui/open-webui/issues/24730), [#24690](https://github.com/open-webui/open-webui/pull/24690)
- 🔍 **Web search reliability.** Web search again fetches page content reliably with the default web loader engine, a new "USER_AGENT" environment variable lets administrators set a real browser user-agent so fetches aren't blocked by Cloudflare, Wikipedia, and other bot-detection systems, and the startup script no longer fails to launch when these new environment variables are unset. [#24560](https://github.com/open-webui/open-webui/issues/24560), [#24793](https://github.com/open-webui/open-webui/issues/24793), [#24683](https://github.com/open-webui/open-webui/pull/24683), [Commit](https://github.com/open-webui/open-webui/commit/f60733758272c4532cd032e518c9cd73f648043a)
- 🔥 **Firecrawl web search results.** Web search using Firecrawl now returns results correctly regardless of which response format the Firecrawl version uses. [#24712](https://github.com/open-webui/open-webui/pull/24712)
- 🦅 **Kagi web search.** Web search using Kagi works again after its API endpoint and request method were updated to match Kagi's current API. [#25015](https://github.com/open-webui/open-webui/pull/25015)
- 🔢 **Bracketed numbers in code blocks.** Numbers in square brackets such as "[0]" inside code blocks are no longer stripped out as if they were source citations, so code displays and copies correctly. [#24948](https://github.com/open-webui/open-webui/issues/24948), [Commit](https://github.com/open-webui/open-webui/commit/e90a618f4555cea2c024bb60c1332ca04eed96da)
- 🔌 **API chat completions reliability.** Direct calls to the chat completions API no longer fail with an internal error when no chat session identifier is supplied. [#24553](https://github.com/open-webui/open-webui/issues/24553), [#25235](https://github.com/open-webui/open-webui/issues/25235), [Commit](https://github.com/open-webui/open-webui/commit/bc244fdc90504824b76654880898bf3f6649c299), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc)
- 🖼️ **ComfyUI image generation and editing.** Generating and editing images via a ComfyUI backend now works again, including when ComfyUI is hosted on a private or internal network where URL validation was previously blocking the admin-configured endpoint. [#24565](https://github.com/open-webui/open-webui/issues/24565), [Commit](https://github.com/open-webui/open-webui/commit/7dcd932ad7cb5feab007737c6e0281def5cd47fc), [Commit](https://github.com/open-webui/open-webui/commit/8aa2a42dc7887512e697ff85d044220945ced40f)
- 🖌️ **Image generation with non-standard response headers.** Image generation now works with backends that return valid JSON without a standard content-type header, instead of rejecting the response. [#24838](https://github.com/open-webui/open-webui/pull/24838)
- 🐘 **Knowledge search on large documents.** Searching knowledge bases on PostgreSQL no longer fails when scanning across documents with very large extracted text content. [#24670](https://github.com/open-webui/open-webui/issues/24670), [Commit](https://github.com/open-webui/open-webui/commit/d74ee34d9128295faa116c919bd1dcca77744975)
- 💬 **Chat title generation.** Automatically generated chat titles now use the model currently selected in the dropdown for the active chat and fall back to the model from the active message branch otherwise, and a clear message is shown if no model is available instead of an unhelpful error. [#24604](https://github.com/open-webui/open-webui/issues/24604), [#24745](https://github.com/open-webui/open-webui/issues/24745), [Commit](https://github.com/open-webui/open-webui/commit/e5c8f8110a88739e011d8ab23e5cb11b4768469f), [Commit](https://github.com/open-webui/open-webui/commit/3c5e7968f0b5130890e17f1f2a2a52297cb145a6)
- 🧮 **Message search and analytics consistency.** Edits, deletions, and branch changes made in a chat are now reflected in message search results and analytics counts instead of leaving stale entries behind. [#25205](https://github.com/open-webui/open-webui/pull/25205), [Commit](https://github.com/open-webui/open-webui/commit/aa06200f789a4c3fc54ea9a141cd347d76cda4c5)
- 🩹 **Graceful handling of in-chat task failures.** When web search query generation, image prompt generation, or a tool call fails or references a missing tool, the chat now falls back or surfaces a clear error instead of breaking partway through the response. [#25038](https://github.com/open-webui/open-webui/issues/25038), [#25144](https://github.com/open-webui/open-webui/issues/25144), [Commit](https://github.com/open-webui/open-webui/commit/b64fd988f02b8a4295193892b24b00bdf635a4dc)
- 🎛️ **Filter changes to message output.** Filter functions that modify a message's structured output after generation now have those changes saved and displayed, instead of being discarded when only the output, not the text content, was changed. [#24884](https://github.com/open-webui/open-webui/pull/24884)
- ⏩ **Titles and tags reflect filtered output.** Outlet filters now run before automatic title, tag, and follow-up generation, so those are based on the final filtered message instead of the unfiltered version. [#24717](https://github.com/open-webui/open-webui/pull/24717)
- 💾 **Action-replaced message content persists.** Message content replaced by an action function through its event emitter is now kept when the chat is saved, instead of reverting to the original after a page reload. [#24585](https://github.com/open-webui/open-webui/issues/24585), [#25485](https://github.com/open-webui/open-webui/pull/25485)
- 🏷️ **Skill mentions in messages.** Mentioning a skill in a message now keeps the skill's name as readable text instead of removing it, and selecting a skill without typing anything no longer causes an error on providers that reject empty messages. [#24929](https://github.com/open-webui/open-webui/issues/24929), [Commit](https://github.com/open-webui/open-webui/commit/01810e32ad51305ca3247e8f83da95f6e6260f0c)
- 🧹 **Usage timer cleanup on send failure.** The background usage-stats timer started during message generation is now always cleared, even when sending a message fails, preventing leaked timers from accumulating over a session. [#25478](https://github.com/open-webui/open-webui/pull/25478)
- 🗑️ **Background tasks stop when a chat is removed.** Deleting or archiving a chat now cancels any in-flight generation or title and tag tasks for it, instead of leaving orphaned background work running. [#25050](https://github.com/open-webui/open-webui/pull/25050), [Commit](https://github.com/open-webui/open-webui/commit/778dba1d6b8dc3962163fa7bf9d802d9a07fba26)
- ⌨️ **Responsive knowledge file search.** Searching for knowledge files in the chat picker and model knowledge selector now matches on file names by default instead of scanning the full extracted text of every document on each keystroke, keeping the search responsive on large deployments, with content search available as an explicit opt-in. [#25082](https://github.com/open-webui/open-webui/issues/25082), [#25119](https://github.com/open-webui/open-webui/pull/25119), [Commit](https://github.com/open-webui/open-webui/commit/591e0aafa1d5e21dcd9fa1279dada2076968b9cb)
- 📥 **Document processing with empty embeddings.** Saving documents to the vector database no longer crashes when an embedding step returns no vectors, allowing the process to continue instead of failing the whole upload. [#25166](https://github.com/open-webui/open-webui/issues/25166)
- 🔤 **Non-UTF-8 text and CSV uploads.** Text and CSV files saved in legacy encodings, including Latin-1, Windows-1252, and Chinese encodings such as GB18030, are now detected and loaded correctly instead of being rejected as binary or failing with an empty-content error. [#25172](https://github.com/open-webui/open-webui/issues/25172), [#24973](https://github.com/open-webui/open-webui/issues/24973), [Commit](https://github.com/open-webui/open-webui/commit/6f0277db52d005420480abb0702d421525d6ea8b), [Commit](https://github.com/open-webui/open-webui/commit/1bbb2b933d4cee70a5102eca4109e77b8625f8fc)
- 🧽 **Null bytes in nested data no longer break saves.** Data containing null bytes nested inside structured fields is now sanitized correctly before being written, preventing database errors that the previous check failed to catch. [#25018](https://github.com/open-webui/open-webui/pull/25018), [Commit](https://github.com/open-webui/open-webui/commit/e3ab4bd212e44c39f439ec4ff5df7c3dbd046895)
- 🧠 **Clear error when no embedding model is configured.** Using knowledge or retrieval features without a loaded embedding model now returns a clear setup error explaining what to configure, instead of failing with a cryptic crash. [Commit](https://github.com/open-webui/open-webui/commit/55ca719bbf76306ed647424c728f730d0bef21f9)
- 🧲 **Memory search quality.** Memory searches now apply the configured embedding query prefix, so retrieval works correctly with embedding models that require one for queries. [#24921](https://github.com/open-webui/open-webui/pull/24921), [Commit](https://github.com/open-webui/open-webui/commit/ce4dca47cb19a6582fd8a550806c89ab297c038d)
- 📚 **Knowledge tool context overflow.** The built-in tool that lists a model's knowledge no longer dumps every file in every knowledge base into the model's context; it now returns summaries by default and paginates file listings only for a requested knowledge base. [#25105](https://github.com/open-webui/open-webui/pull/25105), [Commit](https://github.com/open-webui/open-webui/commit/0e73f7af099e1f9437c55a5c2d16741c97dbd57f)
- ⏳ **Terminal session stability.** The terminal proxy no longer hangs when one direction of the connection closes before the other, so terminal sessions shut down cleanly instead of stalling. [#25464](https://github.com/open-webui/open-webui/issues/25464), [#25479](https://github.com/open-webui/open-webui/pull/25479)
- 🧷 **Tool call continuity with strict providers.** Chats that contain incomplete tool calls or orphaned tool results no longer fail to continue when sent to providers that strictly validate tool pairings, such as Anthropic and AWS Bedrock Converse. [#24758](https://github.com/open-webui/open-webui/issues/24758), [#24940](https://github.com/open-webui/open-webui/issues/24940), [#24798](https://github.com/open-webui/open-webui/pull/24798), [Commit](https://github.com/open-webui/open-webui/commit/cfa6908d579e1f7f202a321289029996130b8411)
- 🛑 **Stream termination for pipe functions.** Streamed responses from pipe functions now always send the standard end-of-stream marker, so chat clients and external integrations reliably detect when a response is complete instead of waiting on streams that already finished. [#24763](https://github.com/open-webui/open-webui/pull/24763)
- 🔊 **Non-blocking text-to-speech transcoding.** Converting text-to-speech audio to MP3 no longer blocks the server's event loop, so other requests stay responsive even while a TTS response is being transcoded. [#24876](https://github.com/open-webui/open-webui/pull/24876)
- 🎚️ **Default text-to-speech voice.** Text-to-speech requests now honor the voice specified in the request and fall back to the configured default only when none is given, instead of always using the admin default or failing. [#15143](https://github.com/open-webui/open-webui/issues/15143), [#25035](https://github.com/open-webui/open-webui/issues/25035), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc), [Commit](https://github.com/open-webui/open-webui/commit/750604a11d4adcb5ae568b9fc93010031ad394f9)
- 🪝 **Reliable knowledge base file linking.** Files uploaded to a knowledge collection are now linked on the server as part of the upload itself, so they remain attached to the collection even if you navigate away or close the page before processing finishes. [#24807](https://github.com/open-webui/open-webui/issues/24807), [Commit](https://github.com/open-webui/open-webui/commit/d0b17f056911ec73df2b686a0d92bb789391db27)
- ☁️ **Azure connections on custom hostnames.** Connections marked as the Azure provider now use the Azure code path even when the endpoint does not contain "azure" in its hostname, fixing custom Azure deployments served from non-standard domains. [#24882](https://github.com/open-webui/open-webui/pull/24882), [Commit](https://github.com/open-webui/open-webui/commit/c8f851bd2de3d127f1e818fc116c27174f96d3f1)
- 🗓️ **Clearing calendar event fields.** Removing the description or location from a calendar event now saves correctly instead of silently keeping the previous value. [#25026](https://github.com/open-webui/open-webui/issues/25026), [Commit](https://github.com/open-webui/open-webui/commit/91810f1c4e93d4f559bdf61cd085cadc288a732d), [Commit](https://github.com/open-webui/open-webui/commit/78b1637a035d71099262412e5dee3e4d65c7fb2f)
- 💭 **Advanced parameter settings.** Custom reasoning tags and custom model parameters are now saved correctly instead of being dropped, and the presence penalty and repeat penalty no longer save the frequency penalty's value instead of their own. [#25183](https://github.com/open-webui/open-webui/pull/25183), [#25200](https://github.com/open-webui/open-webui/pull/25200), [#25204](https://github.com/open-webui/open-webui/pull/25204)
- 📏 **Long username display.** Long usernames no longer overflow their containers in the admin user list, user modals, and sidebar. [#25185](https://github.com/open-webui/open-webui/pull/25185)
- 🎯 **All skills selectable in the model editor.** The model editor's skills selector now lists every skill you have access to, with a search box for large lists, instead of showing only the first 30 with no way to reach the rest. [#24873](https://github.com/open-webui/open-webui/issues/24873), [Commit](https://github.com/open-webui/open-webui/commit/936d5f2676dfbd2ba763b30af33a8557dcda9b05)
- 🔔 **Accurate knowledge upload feedback.** Dragging files into a knowledge base no longer shows an upload notification before the upload has actually been processed. [#25484](https://github.com/open-webui/open-webui/pull/25484)
- ♿ **High-contrast timestamp readability.** The user message timestamp now uses the correct colors in high-contrast mode instead of inverted ones, keeping it readable. [#25461](https://github.com/open-webui/open-webui/pull/25461)
- ♿ **Keyboard and screen reader access to menus.** The integrations, more-options, and user menus are now real buttons with labels and keyboard support, so they can be opened with the keyboard and announced by screen readers. [Commit](https://github.com/open-webui/open-webui/commit/346dab3d8f909fc321a49ea2be633ea5c4c4a349)
- 🖱️ **Focus-loss handling in editors.** Workspace and admin editors for models, tools, functions, and skills again respond correctly when the browser window loses focus, after the wrong event name was being listened for. [#25459](https://github.com/open-webui/open-webui/pull/25459)
- 🛟 **Resilience to corrupted local storage.** Corrupted data in the browser's local storage no longer crashes the interface; affected settings and dismissed-banner state now fall back to safe defaults. [#25481](https://github.com/open-webui/open-webui/pull/25481)
- 📶 **Quieter reconnection notifications.** Brief connection interruptions, such as backgrounding a mobile tab, no longer flash a "connection lost" warning, and the "reconnected" message only appears if a disconnect was actually shown. [Commit](https://github.com/open-webui/open-webui/commit/77c8c54b1ea07189e21313f9cd401f3b956bb93a)
- 🍎 **Safari PDF handling.** PDF processing now works in Safari, which doesn't support the stream iteration the previous code relied on. [#25151](https://github.com/open-webui/open-webui/issues/25151), [#25473](https://github.com/open-webui/open-webui/pull/25473)
- 🎙️ **Voice mode mute shortcut listing.** The keyboard shortcut for muting voice mode now appears in the keyboard shortcuts help modal. [#25193](https://github.com/open-webui/open-webui/pull/25193)
- 📎 **Document attachments in channel model replies.** Tagging a model in a channel thread now forwards uploaded non-image documents such as PDFs and DOCX files into the model's context, so document summarization and comparison workflows that already worked in direct chat now work in channels too. [#24896](https://github.com/open-webui/open-webui/issues/24896), [#24898](https://github.com/open-webui/open-webui/pull/24898), [Commit](https://github.com/open-webui/open-webui/commit/7e9d41d664d7065a92ac93606d18e88c45eb36b6)
- 🙈 **Hidden models in channel mentions.** Models marked as hidden no longer appear in the channel message-input model mention selector, matching how hidden models are excluded elsewhere in the interface. [#24892](https://github.com/open-webui/open-webui/pull/24892)
- 🧵 **Channel thread and pinned message stability.** Opening a channel thread or the pinned messages view no longer fails to render when a message or its data is missing. [#25209](https://github.com/open-webui/open-webui/pull/25209)
- 📺 **YouTube short link transcripts.** Pasting a "youtu.be" short link into a chat now loads the video transcript correctly instead of failing with an empty-content error. [#24856](https://github.com/open-webui/open-webui/issues/24856), [Commit](https://github.com/open-webui/open-webui/commit/1e36a206008c3abce5ccdc98d5750e30a7345b98)
- 🙉 **Hidden models in default-model and automation pickers.** The admin pickers for default models and default pinned models, and the automation model dropdown, now filter out hidden models, consistent with how hidden models are treated elsewhere. [#24869](https://github.com/open-webui/open-webui/issues/24869), [Commit](https://github.com/open-webui/open-webui/commit/1fa3050f069a72de1daaaa29233b545c4deaea51), [Commit](https://github.com/open-webui/open-webui/commit/4705c2d98812c1189c2ae4960399bc8888d4861c)
- 🔊 **Speech-to-text SSL setting honored.** Speech-to-text requests now respect the "AIOHTTP_CLIENT_SESSION_SSL" setting, so administrators using self-signed certificates or custom SSL configurations can use STT engines that were previously failing TLS verification. [#24568](https://github.com/open-webui/open-webui/issues/24568), [#24857](https://github.com/open-webui/open-webui/pull/24857), [Commit](https://github.com/open-webui/open-webui/commit/2ca91ceeeca6a6a21e5a6ad68ea3ab8c9d9f6deb), [Commit](https://github.com/open-webui/open-webui/commit/94b66b17972e0ad77954df4b81a6bb86a7a6a04b)
- 🔗 **Placeholders in MCP connection headers.** Custom header templates configured on MCP server connections now have their "{{USER_ID}}", "{{USER_NAME}}", "{{USER_EMAIL}}", "{{USER_ROLE}}", "{{CHAT_ID}}", and "{{MESSAGE_ID}}" placeholders interpolated at request time, matching how custom headers already work for direct connections and tool servers. [#24822](https://github.com/open-webui/open-webui/pull/24822)
- 🪟 **Bing search CLI smoke test.** Running the Bing web-search module from the command line for a quick connectivity check no longer raises an error about missing arguments. [#24765](https://github.com/open-webui/open-webui/issues/24765), [#24768](https://github.com/open-webui/open-webui/pull/24768)
- 🩺 **Database health check recovery.** After a transient database connection error, the health check endpoint now recovers automatically instead of staying permanently broken on the affected worker. [Commit](https://github.com/open-webui/open-webui/commit/0b81520e072bb4ed15532b8e153b43dd3243feaf)
- 🥾 **Startup on non-Unicode consoles.** Open WebUI no longer crashes at startup when the console can't encode the banner's box-drawing characters, such as on Windows or with redirected or headless output, falling back to a plain-text banner instead. [#24965](https://github.com/open-webui/open-webui/issues/24965), [#25482](https://github.com/open-webui/open-webui/pull/25482)
- 🆕 **First admin signup after a reset.** Creating the first administrator account is no longer blocked by a previously stored signup setting, so a fresh or reset instance can always be bootstrapped. [#24821](https://github.com/open-webui/open-webui/pull/24821)
- 🪵 **JSON exception logging.** With JSON log formatting enabled, exceptions are now recorded correctly with a structured type, message, and stacktrace instead of being dropped, and a logging failure can no longer crash the application. [#25135](https://github.com/open-webui/open-webui/issues/25135), [Commit](https://github.com/open-webui/open-webui/commit/79bf3d28d88e78b0136ef3c3c9f8e7bb85d3cea9)
- 🧭 **Workspace skills permission.** Users granted only the "workspace.skills" permission can now see the workspace entry in the sidebar and are correctly routed to the skills page from the workspace index. [#24729](https://github.com/open-webui/open-webui/pull/24729)
- 🔁 **Resilient database migrations.** Database migrations now skip tables, indexes, and columns that already exist and add missing primary keys to legacy tables, so upgrades succeed even when parts of the schema were manually or partially created beforehand. [Commit](https://github.com/open-webui/open-webui/commit/81f611fb73c726cfcfc20ee57a926a543f07e95f), [Commit](https://github.com/open-webui/open-webui/commit/f0e88dadc8502ea05b1a00f3155bee7d1cf32249), [Commit](https://github.com/open-webui/open-webui/commit/bd9f82d5a681ee94bde44325033447500a0c76bf), [Commit](https://github.com/open-webui/open-webui/commit/459b1c3fda2ec3579fbd2ab408d0fdeb07c96b99), [Commit](https://github.com/open-webui/open-webui/commit/6df09a4039d181f4e2d41324e93cd36c6fb27dfd), [Commit](https://github.com/open-webui/open-webui/commit/95840e307a66429168775de389b329487a4311c4), [Commit](https://github.com/open-webui/open-webui/commit/6b1df94bf933af92f5c7d093a2d92e2e50d6fdd0), [Commit](https://github.com/open-webui/open-webui/commit/98d3b2308564e2112290773ea240b90419ebb48d), [Commit](https://github.com/open-webui/open-webui/commit/1b9d22e324181b96511a15cbacc40fdbb439ad79), [Commit](https://github.com/open-webui/open-webui/commit/dc0f8ae6f2372b6e5dd42f3520813b09c54146c0), [Commit](https://github.com/open-webui/open-webui/commit/ee3b14233a642ff1bc38aef03a2418afcf5d6c1d), [Commit](https://github.com/open-webui/open-webui/commit/1004dad2749bcc369d511315a808d4bb5277854c), [Commit](https://github.com/open-webui/open-webui/commit/db2b3d7fd86cc7d76424671ead3c62a3f6302fe7), [Commit](https://github.com/open-webui/open-webui/commit/2e1b671e8db49f47d69fd59ece2b58e109ec8b84), [Commit](https://github.com/open-webui/open-webui/commit/9a8969ca93f8c9109d956518a085be3e2afacc07), [Commit](https://github.com/open-webui/open-webui/commit/9717ada92fdb3761a217d309f322ee9fbe418d0d), [Commit](https://github.com/open-webui/open-webui/commit/d7cfc1e46a8f3e5c8f6758c12ef641f41fb51a39), [Commit](https://github.com/open-webui/open-webui/commit/9263b7568eaa83f43e4a118c98490c4f7aaba570), [Commit](https://github.com/open-webui/open-webui/commit/73d2065227e651cf90a2cfade721516815743ab4), [#24722](https://github.com/open-webui/open-webui/pull/24722)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
- ⚙️ **Tool-call iteration cap renamed and raised.** The environment variable that limits how many tool calls a single chat response may make is now "CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS", with its default raised from 30 to 256 and a new "-1" value for unlimited; the previous "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" name continues to work as a fallback, and chats that hit the cap now show a clear error in-chat instead of stopping silently. [#24918](https://github.com/open-webui/open-webui/pull/24918), [Commit](https://github.com/open-webui/open-webui/commit/2b99945d2726bdf5aed7b1712f9e3b7b622671df)
- 🔐 **Reduced public "/api/config" exposure.** The "/api/config" response no longer includes several feature flags ("enable_api_keys", "enable_password_change_form", "enable_version_update_check", "enable_public_active_users_count", "enable_easter_eggs") for unauthenticated callers, reducing information disclosure to anonymous visitors. [Commit](https://github.com/open-webui/open-webui/commit/245e0ee029e9e10617f62953a9e6f67dd00ecf81), [Commit](https://github.com/open-webui/open-webui/commit/ae06e199d5d3f65296a978cc35079bdacba596d2)
- 🔑 **"WEBUI_SECRET_KEY" is now a hard requirement even for unsupported deployments.** Deployments that start the backend in an explicitly unsupported way (such as invoking uvicorn directly) without setting "WEBUI_SECRET_KEY" will now refuse to start instead of falling back to an empty key; the supported start methods (start.sh, start_windows.bat, and "open-webui serve") still set or auto-generate it automatically, so standard deployments are unaffected. Direct Uvicorn startup is not supported. [#25218](https://github.com/open-webui/open-webui/pull/25218)
## [0.9.5] - 2026-05-09
### Added
- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491)
- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454)
- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635)
- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981)
- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats.
### Fixed
- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486)
- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494)
- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493)
- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508)
- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524)
- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524)
- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513)
- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506)
- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521)
- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518)
- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525)
- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534)
- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e)
- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9)
- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950)
- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a)
- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f)
- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503)
### Changed
- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497)
- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review.
## [0.9.4] - 2026-05-09
### Fixed
- 📜 **Chat scroll position on load.** Opening a chat conversation now reliably scrolls to the bottom of the message history, fixing a regression caused by `content-visibility: auto` where estimated element sizes prevented the initial scroll from reaching the true bottom.
## [0.9.3] - 2026-05-09
### Added
+1 -1
View File
@@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
RUN npm run build
######## WebUI backend ########
FROM python:3.11.14-slim-bookworm AS base
FROM python:3.11-slim-bookworm AS base
# Use args
ARG USE_CUDA
+55 -25
View File
@@ -27,58 +27,84 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
- **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
- **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
- **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
- **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
- 🗄 **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## The Open WebUI Ecosystem 🌐
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
-**Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
@@ -222,6 +248,10 @@ This project contains code under multiple licenses. The current codebase include
If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Security 🛡️
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">
+5 -1
View File
@@ -11,6 +11,7 @@ import uvicorn
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
DEFAULT_SECRET_KEY_LENGTH = 24
def version_callback(value: bool) -> None:
@@ -37,8 +38,11 @@ def serve(
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
+1900 -3170
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from enum import Enum
@@ -72,11 +74,11 @@ class ERROR_MESSAGES(str, Enum):
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}'
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
+506 -507
View File
File diff suppressed because it is too large Load Diff
+19 -26
View File
@@ -1,11 +1,10 @@
import logging
import sys
import asyncio
import inspect
import json
import asyncio
from pydantic import BaseModel
import logging
import sys
from typing import AsyncGenerator, Generator, Iterator
from fastapi import (
Depends,
FastAPI,
@@ -16,40 +15,35 @@ from fastapi import (
UploadFile,
status,
)
from pydantic import BaseModel
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.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
)
from open_webui.models.users import UserModel
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import check_model_access
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
get_last_user_message,
prepend_to_first_user_message_content,
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
prepend_to_first_user_message_content,
)
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.plugin import (
get_function_module_from_cache,
load_function_module_by_id,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@@ -324,11 +318,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
async for line in res:
yield process_line(form_data, line)
if isinstance(res, str) or isinstance(res, Generator):
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type='text/event-stream')
else:
+32 -68
View File
@@ -1,36 +1,36 @@
import os
import sys
from __future__ import annotations
import json
import logging
import os
import sys
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.internal.wrappers import register_connection
from open_webui.env import (
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SCHEMA,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_URL,
ENABLE_DB_MIGRATIONS,
OPEN_WEBUI_DIR,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import Dialect, MetaData, create_engine, event, types
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.sql.type_api import _T
from typing_extensions import Self
@@ -117,61 +117,25 @@ extract_ssl_mode_from_url = extract_ssl_params_from_url
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
class JSONField(types.TypeDecorator):
impl = types.Text
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
"""Store arbitrary Python objects as JSON-encoded TEXT.
Used instead of native JSON columns for portability across SQLite and
PostgreSQL. Values are serialized with ``json.dumps`` on write and
deserialized with ``json.loads`` on read.
"""
impl = types.UnicodeText
cache_ok = True
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
return json.dumps(value)
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return json.dumps(value) if value is not None else None
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
if value is not None:
return json.loads(value)
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return json.loads(value) if value is not None else None
def copy(self, **kw: Any) -> Self:
return JSONField(self.impl.length)
def db_value(self, value):
return json.dumps(value)
def python_value(self, value):
if value is not None:
return json.loads(value)
# Workaround to handle the peewee migration
# This is required to ensure the peewee migration is handled before the alembic migration
def handle_peewee_migration(DATABASE_URL):
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
# and cert-file params are preserved in the connection string.
url_without_ssl, ssl_params = extract_ssl_params_from_url(DATABASE_URL)
normalized_url = reattach_ssl_params_to_url(url_without_ssl, ssl_params)
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
db.close()
except Exception as e:
log.error(f'Failed to initialize the database connection: {e}')
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
raise
finally:
# Properly closing the database connection
if db and not db.is_closed():
db.close()
# Assert if db connection has been closed
if db is not None:
assert db.is_closed(), 'Database connection is still open.'
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
# Normalize SSL params from the URL once; the sync engine needs them
@@ -410,7 +374,7 @@ async def get_async_db():
@asynccontextmanager
async def get_async_db_context(db: Optional[AsyncSession] = None):
async def get_async_db_context(db: AsyncSession | None = None):
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
yield db
@@ -1,253 +0,0 @@
"""Peewee migrations -- 001_initial_schema.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# We perform different migrations for SQLite and other databases
# This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
# will require per-database SQL queries.
# Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
# schema instead of trying to migrate from an older schema.
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.CharField(max_length=255)
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.CharField()
filename = pw.CharField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.TextField()
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.TextField()
filename = pw.TextField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('user')
migrator.remove_model('tag')
migrator.remove_model('prompt')
migrator.remove_model('modelfile')
migrator.remove_model('document')
migrator.remove_model('chatidtag')
migrator.remove_model('chat')
migrator.remove_model('auth')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'share_id')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'api_key')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', archived=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'archived')
@@ -1,125 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.DateTimeField(null=True), # Allow null for transition
updated_at=pw.DateTimeField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.DateTimeField(null=False),
updated_at=pw.DateTimeField(null=False),
)
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
rollback_sqlite(migrator, database, fake=fake)
else:
rollback_external(migrator, database, fake=fake)
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False))
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False))
@@ -1,129 +0,0 @@
"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'document',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'user',
timestamp=pw.BigIntegerField(),
)
# Alter the tables with varchar to text where necessary
migrator.change_fields(
'auth',
password=pw.TextField(),
)
migrator.change_fields(
'chat',
title=pw.TextField(),
)
migrator.change_fields(
'document',
title=pw.TextField(),
filename=pw.TextField(),
)
migrator.change_fields(
'prompt',
title=pw.TextField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.TextField(),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.DateField(),
)
migrator.change_fields(
'document',
timestamp=pw.DateField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.DateField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.DateField(),
)
migrator.change_fields(
'user',
timestamp=pw.DateField(),
)
migrator.change_fields(
'auth',
password=pw.CharField(max_length=255),
)
migrator.change_fields(
'chat',
title=pw.CharField(),
)
migrator.change_fields(
'document',
title=pw.CharField(),
filename=pw.CharField(),
)
migrator.change_fields(
'prompt',
title=pw.CharField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.CharField(),
)
@@ -1,78 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields created_at and updated_at to the 'user' table
migrator.add_fields(
'user',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
)
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('user', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'user',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
last_active_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE "user" SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False))
@@ -1,52 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Memory(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
content = pw.TextField(null=False)
updated_at = pw.BigIntegerField(null=False)
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'memory'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('memory')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Model(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
base_model_id = pw.TextField(null=True)
name = pw.TextField()
meta = pw.TextField()
params = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'model'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('model')
@@ -1,130 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
import json
from open_webui.utils.misc import parse_ollama_modelfile
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Fetch data from 'modelfile' table and insert into 'model' table
migrate_modelfile_to_model(migrator, database)
# Drop the 'modelfile' table
migrator.remove_model('modelfile')
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
ModelFile = migrator.orm['modelfile']
Model = migrator.orm['model']
modelfiles = ModelFile.select()
for modelfile in modelfiles:
# Extract and transform data in Python
modelfile.modelfile = json.loads(modelfile.modelfile)
meta = json.dumps(
{
'description': modelfile.modelfile.get('desc'),
'profile_image_url': modelfile.modelfile.get('imageUrl'),
'ollama': {'modelfile': modelfile.modelfile.get('content')},
'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'),
'categories': modelfile.modelfile.get('categories'),
'user': {**modelfile.modelfile.get('user', {}), 'community': True},
}
)
info = parse_ollama_modelfile(modelfile.modelfile.get('content'))
# Insert the processed data into the 'model' table
Model.create(
id=f'ollama-{modelfile.tag_name}',
user_id=modelfile.user_id,
base_model_id=info.get('base_model_id'),
name=modelfile.modelfile.get('title'),
meta=meta,
params=json.dumps(info.get('params', {})),
created_at=modelfile.timestamp,
updated_at=modelfile.timestamp,
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
recreate_modelfile_table(migrator, database)
move_data_back_to_modelfile(migrator, database)
migrator.remove_model('model')
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
query = """
CREATE TABLE IF NOT EXISTS modelfile (
user_id TEXT,
tag_name TEXT,
modelfile JSON,
timestamp BIGINT
)
"""
migrator.sql(query)
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
Model = migrator.orm['model']
Modelfile = migrator.orm['modelfile']
models = Model.select()
for model in models:
# Extract and transform data in Python
meta = json.loads(model.meta)
modelfile_data = {
'title': model.name,
'desc': meta.get('description'),
'imageUrl': meta.get('profile_image_url'),
'content': meta.get('ollama', {}).get('modelfile'),
'suggestionPrompts': meta.get('suggestion_prompts'),
'categories': meta.get('categories'),
'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'},
}
# Insert the processed data back into the 'modelfile' table
Modelfile.create(
user_id=model.user_id,
tag_name=model.id,
modelfile=modelfile_data,
timestamp=model.created_at,
)
@@ -1,47 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields settings to the 'user' table
migrator.add_fields('user', settings=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'settings')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Tool(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
content = pw.TextField()
specs = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'tool'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('tool')
@@ -1,47 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields info to the 'user' table
migrator.add_fields('user', info=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'info')
@@ -1,54 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class File(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
filename = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'file'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('file')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Function(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
type = pw.TextField()
content = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'function'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('function')
@@ -1,49 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('tool', valves=pw.TextField(null=True))
migrator.add_fields('function', valves=pw.TextField(null=True))
migrator.add_fields('function', is_active=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('tool', 'valves')
migrator.remove_fields('function', 'valves')
migrator.remove_fields('function', 'is_active')
@@ -1,44 +0,0 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'user',
oauth_sub=pw.TextField(null=True, unique=True),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'oauth_sub')
@@ -1,48 +0,0 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'function',
is_global=pw.BooleanField(default=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('function', 'is_global')
-84
View File
@@ -1,84 +0,0 @@
import logging
import os
from contextvars import ContextVar
from peewee import *
from peewee import InterfaceError as PeeWeeInterfaceError
from peewee import PostgresqlDatabase
from playhouse.db_url import connect, parse
from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__)
db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None}
db_state = ContextVar('db_state', default=db_state_default.copy())
class PeeweeConnectionState(object):
def __init__(self, **kwargs):
super().__setattr__('_state', db_state)
super().__init__(**kwargs)
def __setattr__(self, name, value):
self._state.get()[name] = value
def __getattr__(self, name):
value = self._state.get()[name]
return value
class CustomReconnectMixin(ReconnectMixin):
reconnect_errors = (
# psycopg2
(OperationalError, 'termin'),
(InterfaceError, 'closed'),
# peewee
(PeeWeeInterfaceError, 'closed'),
)
class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
pass
def register_connection(db_url):
# Check if using SQLCipher protocol
if db_url.startswith('sqlite+sqlcipher://'):
database_password = os.environ.get('DATABASE_PASSWORD')
if not database_password or database_password.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace('sqlite+sqlcipher://', '')
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to encrypted SQLite database using SQLCipher')
else:
# Standard database connection (existing logic)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to PostgreSQL database')
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to SQLite database')
else:
raise ValueError('Unsupported database connection')
return db
+602 -1145
View File
File diff suppressed because it is too large Load Diff
+64 -100
View File
@@ -1,120 +1,84 @@
import logging
from logging.config import fileConfig
from __future__ import annotations
from alembic import context
# Alembic environment configuration runner.
# Coordinates database migrations in both offline and online execution modes.
import logging.config
import logging
import alembic.context
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
from open_webui.internal.db import 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.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
from sqlalchemy import engine_from_config, pool, create_engine
from sqlalchemy import create_engine, engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
alembic_config = alembic.context.config
if alembic_config.config_file_name:
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
if LOG_FORMAT == 'json':
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Auth.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2 for sync migrations).
url_without_ssl, ssl_params = extract_ssl_params_from_url(DB_URL)
DB_URL = reattach_ssl_params_to_url(url_without_ssl, ssl_params) if ssl_params else DB_URL
if DB_URL:
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
for log_handler in logging.root.handlers:
log_handler.setFormatter(JSONFormatter())
migration_metadata = Auth.metadata
target_db_url = DATABASE_URL
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
if ssl_query_params:
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
if target_db_url:
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure(
url=db_connection_url,
target_metadata=migration_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
with context.begin_transaction():
context.run_migrations()
def _get_engine_connectable():
"""Build the database engine based on target URL and authentication credentials."""
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
if raw_db_path.startswith('/'):
raw_db_path = raw_db_path[1:]
def _sqlite_cipher_creator():
import sqlcipher3
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return cipher_conn
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
return engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
# Extract database path from SQLCipher URL
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
if db_path.startswith('/'):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return conn
connectable = create_engine(
'sqlite://', # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
"""Execute migrations against a live database connection."""
live_connectable = _get_engine_connectable()
with live_connectable.connect() as live_connection:
alembic.context.configure(
connection=live_connection,
target_metadata=migration_metadata,
)
else:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
with alembic.context.begin_transaction():
alembic.context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
# Alembic execution entrypoint branch
if alembic.context.is_offline_mode():
run_migrations_offline() # run in offline mode
if not alembic.context.is_offline_mode():
run_migrations_online() # run in online mode
+14 -9
View File
@@ -1,15 +1,20 @@
from alembic import op
from sqlalchemy import Inspector
from __future__ import annotations
"""Alembic migration utilities."""
from alembic import op # noqa: E402 — alembic runtime context
from sqlalchemy import inspect # metadata inspection
def get_existing_tables():
con = op.get_bind()
inspector = Inspector.from_engine(con)
tables = set(inspector.get_table_names())
return tables
# --- database helper functions ---
def get_existing_tables() -> set[str]:
"""Return table names already present in the database."""
conn = op.get_bind()
return set(inspect(conn).get_table_names())
def get_revision_id():
def get_revision_id() -> str:
"""Generate a short random revision identifier."""
import uuid
return str(uuid.uuid4()).replace('-', '')[:12]
return uuid.uuid4().hex[:12]
@@ -6,8 +6,8 @@ Create Date: 2025-08-13 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '018012973d35'
down_revision = 'd31026856c01'
@@ -16,18 +16,34 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
def _idx_exists(table, idx_name):
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
# Chat table indexes
op.create_index('folder_id_idx', 'chat', ['folder_id'])
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
if not _idx_exists('chat', 'folder_id_idx'):
op.create_index('folder_id_idx', 'chat', ['folder_id'])
if not _idx_exists('chat', 'user_id_pinned_idx'):
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
if not _idx_exists('chat', 'user_id_archived_idx'):
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
if not _idx_exists('chat', 'updated_at_user_id_idx'):
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
if not _idx_exists('chat', 'folder_id_user_id_idx'):
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
# Tag table index
op.create_index('user_id_idx', 'tag', ['user_id'])
if not _idx_exists('tag', 'user_id_idx'):
op.create_index('user_id_idx', 'tag', ['user_id'])
# Function table index
op.create_index('is_global_idx', 'function', ['is_global'])
# Function table index (only if is_global column exists — added by a later migration)
conn = op.get_bind()
inspector = sa.inspect(conn)
func_cols = {c['name'] for c in inspector.get_columns('function')}
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
op.create_index('is_global_idx', 'function', ['is_global'])
def downgrade():
@@ -6,13 +6,13 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
revision = '1af9b942657b'
down_revision = '242a2047eae0'
branch_labels = None
@@ -93,8 +93,11 @@ def upgrade():
conn.execute(update_stmt)
# Add columns `pinned` and `meta` to 'chat'
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
if 'pinned' not in chat_columns:
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
if 'meta' not in chat_columns:
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
chat = table(
@@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import select, table, update
revision = '242a2047eae0'
down_revision = '6a39f3d8e55c'
branch_labels = None
@@ -47,29 +47,32 @@ def upgrade():
# If the column is already JSON, no need to do anything
pass
# Step 3: Migrate data from 'old_chat' to 'chat'
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
# Re-check columns after potential rename above
current_cols = {c['name'] for c in inspector.get_columns('chat')}
if 'old_chat' in current_cols:
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
def downgrade():
@@ -8,9 +8,9 @@ Create Date: 2025-11-27 03:07:56.200231
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2f1211949ecc'
@@ -20,63 +20,76 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# New columns to be added to channel_member table
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'status' not in cm_cols:
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
if 'is_active' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
if 'is_channel_muted' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'is_channel_pinned' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'data' not in cm_cols:
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
if 'meta' not in cm_cols:
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
if 'joined_at' not in cm_cols:
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
if 'left_at' not in cm_cols:
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
if 'last_read_at' not in cm_cols:
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
if 'updated_at' not in cm_cols:
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
# New columns to be added to message table
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
msg_cols = {c['name'] for c in inspector.get_columns('message')}
if 'is_pinned' not in msg_cols:
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'pinned_at' not in msg_cols:
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
if 'pinned_by' not in msg_cols:
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
def downgrade() -> None:
@@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
"""
from typing import Sequence, Union
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = '374d2f66af06'
down_revision: Union[str, None] = 'c440947495f3'
@@ -20,150 +20,167 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Step 1: Read existing data from OLD table (schema likely command as PK)
# We use batch_alter previously, but we want to move to new table.
# We need to assume the OLD structure.
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
# the migration completed successfully on a prior run — nothing to do.
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
# prompt_history exists and prompt_new was already renamed → done
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'id' in prompt_cols and 'version_id' in prompt_cols:
return
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
)
# Check if table exists/read data
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
# Step 1: Read existing data from OLD table (schema: command as PK)
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
existing_prompts = []
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
)
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
existing_prompts = []
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
).fetchall()
except Exception:
existing_prompts = []
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 4: Migrate data
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
if 'prompt_new' not in existing_tables:
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
# Step 3: Create prompt_history table (if not already created)
if 'prompt_history' not in existing_tables:
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 5: Replace old table with new one
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
# Step 4: Migrate data (only if we have old data to migrate)
if existing_prompts:
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
)
# Step 5: Replace old table with new one (only if prompt_new exists)
# Re-check tables after potential creation above
inspector.clear_cache()
current_tables = set(inspector.get_table_names())
if 'prompt_new' in current_tables:
if 'prompt' in current_tables:
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
def downgrade() -> None:
@@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '3781e22d8b01'
down_revision = '7826ab40b532'
@@ -16,38 +16,50 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Add 'type' column to the 'channel' table
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'type' not in channel_cols:
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
message_cols = {c['name'] for c in inspector.get_columns('message')}
if 'parent_id' not in message_cols:
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
if 'message_reaction' not in existing_tables:
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
op.create_table(
'channel_member',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
if 'channel_member' not in existing_tables:
op.create_table(
'channel_member',
sa.Column(
'id', sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
def downgrade():
@@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
"""
import uuid
import time
import json
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '37f288994c47'
@@ -22,6 +22,13 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group_member' in existing_tables:
return # Already created — skip everything
# 1. Create new table
op.create_table(
'group_member',
@@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '38d63c18f30f'
@@ -19,50 +19,39 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
columns = inspector.get_columns('user')
existing_tables = set(inspector.get_table_names())
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
id_column = next((col for col in columns if col['name'] == 'id'), None)
# ── Create oauth_session table (idempotent) ───────────────────────
if 'oauth_session' not in existing_tables:
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
if id_column and not id_column.get('unique', False):
unique_constraints = inspector.get_unique_constraints('user')
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
with op.batch_alter_table('user') as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ['id']:
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
# Add unique constraint if missing
if ('id',) not in unique_columns:
batch_op.create_unique_constraint('uq_user_id', ['id'])
# Re-create correct primary key
batch_op.create_primary_key('pk_user_id', ['id'])
# Create oauth_session table
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# Create indexes (idempotent — no-ops when table was just created
# with the columns above, and safe to call if indexes already exist).
existing_indexes = (
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
)
# Create indexes for better performance
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
if 'idx_oauth_session_user_id' not in existing_indexes:
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
if 'idx_oauth_session_expires_at' not in existing_indexes:
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
if 'idx_oauth_session_user_provider' not in existing_indexes:
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
def downgrade() -> None:
@@ -6,13 +6,13 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
revision = '3ab32c4b8f59'
down_revision = '1af9b942657b'
branch_labels = None
@@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3af16a1c9fb6'
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'username' not in user_cols:
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
if 'bio' not in user_cols:
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
if 'gender' not in user_cols:
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
if 'date_of_birth' not in user_cols:
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
def downgrade() -> None:
@@ -0,0 +1,72 @@
"""add knowledge_directory table
Revision ID: 3c9b0ca343fd
Revises: a0b1c2d3e4f5
Create Date: 2026-05-13 21:58:40.832482
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3c9b0ca343fd'
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
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)
existing_tables = set(inspector.get_table_names())
if 'knowledge_directory' not in existing_tables:
# Create knowledge_directory table
op.create_table(
'knowledge_directory',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('knowledge_id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
),
)
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
# Add directory_id column to knowledge_file
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
if 'directory_id' not in kf_cols:
with op.batch_alter_table('knowledge_file') as batch:
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
batch.create_foreign_key(
'fk_knowledge_file_directory_id',
'knowledge_directory',
['directory_id'],
['id'],
ondelete='SET NULL',
)
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
def downgrade() -> None:
# Remove directory_id from knowledge_file
with op.batch_alter_table('knowledge_file') as batch:
batch.drop_index('ix_knowledge_file_directory_id')
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
batch.drop_column('directory_id')
# Drop knowledge_directory table
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
op.drop_table('knowledge_directory')
@@ -6,16 +6,15 @@ Create Date: 2025-12-02 06:54:19.401334
"""
import json
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
import open_webui.internal.db
import time
import json
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '3e0e00844bb0'
@@ -25,6 +24,13 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'knowledge_file' in existing_tables:
return # Already created — skip everything
op.create_table(
'knowledge_file',
sa.Column('id', sa.Text(), primary_key=True),
@@ -0,0 +1,580 @@
"""reshape config to per key rows
Revision ID: 3ff2c63645b8
Revises: 461111b60977
Create Date: 2026-06-17 00:50:51.477073
"""
import json
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3ff2c63645b8'
down_revision: Union[str, None] = '461111b60977'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Maps every dot-notation blob path to its legacy env/config key name.
# Built from the legacy persistent config declarations in config.py.
BLOB_PATH_TO_KEY = {
"audio.stt.allowed_extensions": "AUDIO_STT_ALLOWED_EXTENSIONS",
"audio.stt.azure.api_key": "AUDIO_STT_AZURE_API_KEY",
"audio.stt.azure.base_url": "AUDIO_STT_AZURE_BASE_URL",
"audio.stt.azure.locales": "AUDIO_STT_AZURE_LOCALES",
"audio.stt.azure.max_speakers": "AUDIO_STT_AZURE_MAX_SPEAKERS",
"audio.stt.azure.region": "AUDIO_STT_AZURE_REGION",
"audio.stt.deepgram.api_key": "DEEPGRAM_API_KEY",
"audio.stt.engine": "AUDIO_STT_ENGINE",
"audio.stt.mistral.api_base_url": "AUDIO_STT_MISTRAL_API_BASE_URL",
"audio.stt.mistral.api_key": "AUDIO_STT_MISTRAL_API_KEY",
"audio.stt.mistral.use_chat_completions": "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS",
"audio.stt.model": "AUDIO_STT_MODEL",
"audio.stt.openai.api_base_url": "AUDIO_STT_OPENAI_API_BASE_URL",
"audio.stt.openai.api_key": "AUDIO_STT_OPENAI_API_KEY",
"audio.stt.supported_content_types": "AUDIO_STT_SUPPORTED_CONTENT_TYPES",
"audio.stt.whisper_model": "WHISPER_MODEL",
"audio.tts.api_key": "AUDIO_TTS_API_KEY",
"audio.tts.azure.speech_base_url": "AUDIO_TTS_AZURE_SPEECH_BASE_URL",
"audio.tts.azure.speech_output_format": "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT",
"audio.tts.azure.speech_region": "AUDIO_TTS_AZURE_SPEECH_REGION",
"audio.tts.engine": "AUDIO_TTS_ENGINE",
"audio.tts.mistral.api_base_url": "AUDIO_TTS_MISTRAL_API_BASE_URL",
"audio.tts.mistral.api_key": "AUDIO_TTS_MISTRAL_API_KEY",
"audio.tts.model": "AUDIO_TTS_MODEL",
"audio.tts.openai.api_base_url": "AUDIO_TTS_OPENAI_API_BASE_URL",
"audio.tts.openai.api_key": "AUDIO_TTS_OPENAI_API_KEY",
"audio.tts.openai.params": "AUDIO_TTS_OPENAI_PARAMS",
"audio.tts.split_on": "AUDIO_TTS_SPLIT_ON",
"audio.tts.voice": "AUDIO_TTS_VOICE",
"auth.admin.email": "ADMIN_EMAIL",
"auth.admin.show": "SHOW_ADMIN_DETAILS",
"auth.api_key.allowed_endpoints": "API_KEYS_ALLOWED_ENDPOINTS",
"auth.api_key.endpoint_restrictions": "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
"auth.enable_api_keys": "ENABLE_API_KEYS",
"auth.jwt_expiry": "JWT_EXPIRES_IN",
"automations.enable": "ENABLE_AUTOMATIONS",
"automations.max_count": "AUTOMATION_MAX_COUNT",
"automations.min_interval": "AUTOMATION_MIN_INTERVAL",
"calendar.enable": "ENABLE_CALENDAR",
"channels.enable": "ENABLE_CHANNELS",
"code_execution.enable": "ENABLE_CODE_EXECUTION",
"code_execution.engine": "CODE_EXECUTION_ENGINE",
"code_execution.jupyter.auth": "CODE_EXECUTION_JUPYTER_AUTH",
"code_execution.jupyter.auth_password": "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD",
"code_execution.jupyter.auth_token": "CODE_EXECUTION_JUPYTER_AUTH_TOKEN",
"code_execution.jupyter.timeout": "CODE_EXECUTION_JUPYTER_TIMEOUT",
"code_execution.jupyter.url": "CODE_EXECUTION_JUPYTER_URL",
"code_interpreter.enable": "ENABLE_CODE_INTERPRETER",
"code_interpreter.engine": "CODE_INTERPRETER_ENGINE",
"code_interpreter.jupyter.auth": "CODE_INTERPRETER_JUPYTER_AUTH",
"code_interpreter.jupyter.auth_password": "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
"code_interpreter.jupyter.auth_token": "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
"code_interpreter.jupyter.timeout": "CODE_INTERPRETER_JUPYTER_TIMEOUT",
"code_interpreter.jupyter.url": "CODE_INTERPRETER_JUPYTER_URL",
"code_interpreter.prompt_template": "CODE_INTERPRETER_PROMPT_TEMPLATE",
"direct.enable": "ENABLE_DIRECT_CONNECTIONS",
"evaluation.arena.enable": "ENABLE_EVALUATION_ARENA_MODELS",
"evaluation.arena.models": "EVALUATION_ARENA_MODELS",
"file.image_compression_height": "FILE_IMAGE_COMPRESSION_HEIGHT",
"file.image_compression_width": "FILE_IMAGE_COMPRESSION_WIDTH",
"folders.enable": "ENABLE_FOLDERS",
"folders.max_file_count": "FOLDER_MAX_FILE_COUNT",
"google_drive.api_key": "GOOGLE_DRIVE_API_KEY",
"google_drive.client_id": "GOOGLE_DRIVE_CLIENT_ID",
"google_drive.enable": "ENABLE_GOOGLE_DRIVE_INTEGRATION",
"image_generation.automatic1111.api_auth": "AUTOMATIC1111_API_AUTH",
"image_generation.automatic1111.api_params": "AUTOMATIC1111_PARAMS",
"image_generation.automatic1111.base_url": "AUTOMATIC1111_BASE_URL",
"image_generation.comfyui.api_key": "COMFYUI_API_KEY",
"image_generation.comfyui.base_url": "COMFYUI_BASE_URL",
"image_generation.comfyui.nodes": "COMFYUI_WORKFLOW_NODES",
"image_generation.comfyui.workflow": "COMFYUI_WORKFLOW",
"image_generation.enable": "ENABLE_IMAGE_GENERATION",
"image_generation.engine": "IMAGE_GENERATION_ENGINE",
"image_generation.gemini.api_base_url": "IMAGES_GEMINI_API_BASE_URL",
"image_generation.gemini.api_key": "IMAGES_GEMINI_API_KEY",
"image_generation.gemini.endpoint_method": "IMAGES_GEMINI_ENDPOINT_METHOD",
"image_generation.model": "IMAGE_GENERATION_MODEL",
"image_generation.openai.api_base_url": "IMAGES_OPENAI_API_BASE_URL",
"image_generation.openai.api_key": "IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_version": "IMAGES_OPENAI_API_VERSION",
"image_generation.openai.params": "IMAGES_OPENAI_API_PARAMS",
"image_generation.prompt.enable": "ENABLE_IMAGE_PROMPT_GENERATION",
"image_generation.size": "IMAGE_SIZE",
"image_generation.steps": "IMAGE_STEPS",
"images.edit.comfyui.api_key": "IMAGES_EDIT_COMFYUI_API_KEY",
"images.edit.comfyui.base_url": "IMAGES_EDIT_COMFYUI_BASE_URL",
"images.edit.comfyui.nodes": "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
"images.edit.comfyui.workflow": "IMAGES_EDIT_COMFYUI_WORKFLOW",
"images.edit.enable": "ENABLE_IMAGE_EDIT",
"images.edit.engine": "IMAGE_EDIT_ENGINE",
"images.edit.gemini.api_base_url": "IMAGES_EDIT_GEMINI_API_BASE_URL",
"images.edit.gemini.api_key": "IMAGES_EDIT_GEMINI_API_KEY",
"images.edit.model": "IMAGE_EDIT_MODEL",
"images.edit.openai.api_base_url": "IMAGES_EDIT_OPENAI_API_BASE_URL",
"images.edit.openai.api_key": "IMAGES_EDIT_OPENAI_API_KEY",
"images.edit.openai.api_version": "IMAGES_EDIT_OPENAI_API_VERSION",
"images.edit.size": "IMAGE_EDIT_SIZE",
"ldap.enable": "ENABLE_LDAP",
"ldap.group.enable_creation": "ENABLE_LDAP_GROUP_CREATION",
"ldap.group.enable_management": "ENABLE_LDAP_GROUP_MANAGEMENT",
"ldap.server.app_dn": "LDAP_APP_DN",
"ldap.server.app_password": "LDAP_APP_PASSWORD",
"ldap.server.attribute_for_groups": "LDAP_ATTRIBUTE_FOR_GROUPS",
"ldap.server.attribute_for_mail": "LDAP_ATTRIBUTE_FOR_MAIL",
"ldap.server.attribute_for_username": "LDAP_ATTRIBUTE_FOR_USERNAME",
"ldap.server.ca_cert_file": "LDAP_CA_CERT_FILE",
"ldap.server.ciphers": "LDAP_CIPHERS",
"ldap.server.host": "LDAP_SERVER_HOST",
"ldap.server.label": "LDAP_SERVER_LABEL",
"ldap.server.port": "LDAP_SERVER_PORT",
"ldap.server.search_filter": "LDAP_SEARCH_FILTER",
"ldap.server.use_tls": "LDAP_USE_TLS",
"ldap.server.users_dn": "LDAP_SEARCH_BASE",
"ldap.server.validate_cert": "LDAP_VALIDATE_CERT",
"memories.enable": "ENABLE_MEMORIES",
"models.base_models_cache": "ENABLE_BASE_MODELS_CACHE",
"models.default_metadata": "DEFAULT_MODEL_METADATA",
"models.default_params": "DEFAULT_MODEL_PARAMS",
"notes.enable": "ENABLE_NOTES",
# OAuth — direct paths
"oauth.admin_roles": "OAUTH_ADMIN_ROLES",
"oauth.allowed_domains": "OAUTH_ALLOWED_DOMAINS",
"oauth.allowed_roles": "OAUTH_ALLOWED_ROLES",
"oauth.audience": "OAUTH_AUDIENCE",
"oauth.auto_redirect": "OAUTH_AUTO_REDIRECT",
"oauth.blocked_groups": "OAUTH_BLOCKED_GROUPS",
"oauth.client.timeout": "OAUTH_CLIENT_TIMEOUT",
"oauth.enable_group_creation": "ENABLE_OAUTH_GROUP_CREATION",
"oauth.enable_group_mapping": "ENABLE_OAUTH_GROUP_MANAGEMENT",
"oauth.enable_role_mapping": "ENABLE_OAUTH_ROLE_MANAGEMENT",
"oauth.enable_signup": "ENABLE_OAUTH_SIGNUP",
"oauth.group_default_share": "OAUTH_GROUP_DEFAULT_SHARE",
"oauth.merge_accounts_by_email": "OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
"oauth.refresh_token_include_scope": "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE",
"oauth.roles_claim": "OAUTH_ROLES_CLAIM",
"oauth.update_email_on_login": "OAUTH_UPDATE_EMAIL_ON_LOGIN",
"oauth.update_name_on_login": "OAUTH_UPDATE_NAME_ON_LOGIN",
"oauth.update_picture_on_login": "OAUTH_UPDATE_PICTURE_ON_LOGIN",
# OAuth — generic provider paths
"oauth.client_id": "OAUTH_CLIENT_ID",
"oauth.client_secret": "OAUTH_CLIENT_SECRET",
"oauth.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
"oauth.email_claim": "OAUTH_EMAIL_CLAIM",
"oauth.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
"oauth.group_claim": "OAUTH_GROUP_CLAIM",
"oauth.picture_claim": "OAUTH_PICTURE_CLAIM",
"oauth.provider_name": "OAUTH_PROVIDER_NAME",
"oauth.provider_url": "OPENID_PROVIDER_URL",
"oauth.redirect_uri": "OPENID_REDIRECT_URI",
"oauth.scopes": "OAUTH_SCOPES",
"oauth.sub_claim": "OAUTH_SUB_CLAIM",
"oauth.timeout": "OAUTH_TIMEOUT",
"oauth.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
"oauth.username_claim": "OAUTH_USERNAME_CLAIM",
# OAuth — OIDC nested paths (flattened)
"oauth.oidc.avatar_claim": "OAUTH_PICTURE_CLAIM",
"oauth.oidc.client_id": "OAUTH_CLIENT_ID",
"oauth.oidc.client_secret": "OAUTH_CLIENT_SECRET",
"oauth.oidc.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
"oauth.oidc.email_claim": "OAUTH_EMAIL_CLAIM",
"oauth.oidc.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
"oauth.oidc.group_claim": "OAUTH_GROUP_CLAIM", # renamed from OAUTH_GROUPS_CLAIM
"oauth.oidc.oauth_timeout": "OAUTH_TIMEOUT",
"oauth.oidc.provider_name": "OAUTH_PROVIDER_NAME",
"oauth.oidc.provider_url": "OPENID_PROVIDER_URL",
"oauth.oidc.redirect_uri": "OPENID_REDIRECT_URI",
"oauth.oidc.scopes": "OAUTH_SCOPES",
"oauth.oidc.sub_claim": "OAUTH_SUB_CLAIM",
"oauth.oidc.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
"oauth.oidc.username_claim": "OAUTH_USERNAME_CLAIM",
# OAuth — provider-specific
"oauth.feishu.client_id": "FEISHU_CLIENT_ID",
"oauth.feishu.client_secret": "FEISHU_CLIENT_SECRET",
"oauth.feishu.redirect_uri": "FEISHU_REDIRECT_URI",
"oauth.feishu.scope": "FEISHU_OAUTH_SCOPE",
"oauth.github.client_id": "GITHUB_CLIENT_ID",
"oauth.github.client_secret": "GITHUB_CLIENT_SECRET",
"oauth.github.redirect_uri": "GITHUB_CLIENT_REDIRECT_URI",
"oauth.github.scope": "GITHUB_CLIENT_SCOPE",
"oauth.google.client_id": "GOOGLE_CLIENT_ID",
"oauth.google.client_secret": "GOOGLE_CLIENT_SECRET",
"oauth.google.redirect_uri": "GOOGLE_REDIRECT_URI",
"oauth.google.scope": "GOOGLE_OAUTH_SCOPE",
"oauth.microsoft.client_id": "MICROSOFT_CLIENT_ID",
"oauth.microsoft.client_secret": "MICROSOFT_CLIENT_SECRET",
"oauth.microsoft.login_base_url": "MICROSOFT_CLIENT_LOGIN_BASE_URL",
"oauth.microsoft.picture_url": "MICROSOFT_CLIENT_PICTURE_URL",
"oauth.microsoft.redirect_uri": "MICROSOFT_REDIRECT_URI",
"oauth.microsoft.scope": "MICROSOFT_OAUTH_SCOPE",
"oauth.microsoft.tenant_id": "MICROSOFT_CLIENT_TENANT_ID",
# Ollama / OpenAI
"ollama.api_configs": "OLLAMA_API_CONFIGS",
"ollama.base_urls": "OLLAMA_BASE_URLS",
"ollama.enable": "ENABLE_OLLAMA_API",
"onedrive.enable": "ENABLE_ONEDRIVE_INTEGRATION",
"onedrive.sharepoint_tenant_id": "ONEDRIVE_SHAREPOINT_TENANT_ID",
"onedrive.sharepoint_url": "ONEDRIVE_SHAREPOINT_URL",
"openai.api_base_urls": "OPENAI_API_BASE_URLS",
"openai.api_configs": "OPENAI_API_CONFIGS",
"openai.api_keys": "OPENAI_API_KEYS",
"openai.enable": "ENABLE_OPENAI_API",
# RAG
"rag.content_extraction_engine": "CONTENT_EXTRACTION_ENGINE",
"rag.datalab_marker_use_llm": "DATALAB_MARKER_USE_LLM",
"rag.mistral_ocr_api_base_url": "MISTRAL_OCR_API_BASE_URL",
"rag.azure_openai.api_key": "RAG_AZURE_OPENAI_API_KEY",
"rag.azure_openai.api_version": "RAG_AZURE_OPENAI_API_VERSION",
"rag.azure_openai.base_url": "RAG_AZURE_OPENAI_BASE_URL",
"rag.bypass_embedding_and_retrieval": "BYPASS_EMBEDDING_AND_RETRIEVAL",
"rag.chunk_min_size_target": "CHUNK_MIN_SIZE_TARGET",
"rag.chunk_overlap": "CHUNK_OVERLAP",
"rag.chunk_size": "CHUNK_SIZE",
"rag.datalab_marker_additional_config": "DATALAB_MARKER_ADDITIONAL_CONFIG",
"rag.datalab_marker_api_base_url": "DATALAB_MARKER_API_BASE_URL",
"rag.datalab_marker_api_key": "DATALAB_MARKER_API_KEY",
"rag.datalab_marker_disable_image_extraction": "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION",
"rag.datalab_marker_force_ocr": "DATALAB_MARKER_FORCE_OCR",
"rag.datalab_marker_format_lines": "DATALAB_MARKER_FORMAT_LINES",
"rag.datalab_marker_output_format": "DATALAB_MARKER_OUTPUT_FORMAT",
"rag.datalab_marker_paginate": "DATALAB_MARKER_PAGINATE",
"rag.datalab_marker_skip_cache": "DATALAB_MARKER_SKIP_CACHE",
"rag.datalab_marker_strip_existing_ocr": "DATALAB_MARKER_STRIP_EXISTING_OCR",
"rag.docling_api_key": "DOCLING_API_KEY",
"rag.docling_params": "DOCLING_PARAMS",
"rag.docling_server_url": "DOCLING_SERVER_URL",
"rag.document_intelligence_endpoint": "DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_key": "DOCUMENT_INTELLIGENCE_KEY",
"rag.document_intelligence_model": "DOCUMENT_INTELLIGENCE_MODEL",
"rag.embedding_batch_size": "RAG_EMBEDDING_BATCH_SIZE",
"rag.embedding_concurrent_requests": "RAG_EMBEDDING_CONCURRENT_REQUESTS",
"rag.embedding_engine": "RAG_EMBEDDING_ENGINE",
"rag.embedding_model": "RAG_EMBEDDING_MODEL",
"rag.enable_async_embedding": "ENABLE_ASYNC_EMBEDDING",
"rag.enable_hybrid_search": "ENABLE_RAG_HYBRID_SEARCH",
"rag.enable_hybrid_search_enriched_texts": "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS",
"rag.enable_markdown_header_text_splitter": "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
"rag.external_document_loader_api_key": "EXTERNAL_DOCUMENT_LOADER_API_KEY",
"rag.external_document_loader_url": "EXTERNAL_DOCUMENT_LOADER_URL",
"rag.external_reranker_api_key": "RAG_EXTERNAL_RERANKER_API_KEY",
"rag.external_reranker_timeout": "RAG_EXTERNAL_RERANKER_TIMEOUT",
"rag.external_reranker_url": "RAG_EXTERNAL_RERANKER_URL",
"rag.file.allowed_extensions": "RAG_ALLOWED_FILE_EXTENSIONS",
"rag.file.max_count": "RAG_FILE_MAX_COUNT",
"rag.file.max_size": "RAG_FILE_MAX_SIZE",
"rag.full_context": "RAG_FULL_CONTEXT",
"rag.hybrid_bm25_weight": "RAG_HYBRID_BM25_WEIGHT",
"rag.mineru_api_key": "MINERU_API_KEY",
"rag.mineru_api_mode": "MINERU_API_MODE",
"rag.mineru_api_timeout": "MINERU_API_TIMEOUT",
"rag.mineru_api_url": "MINERU_API_URL",
"rag.mineru_file_extensions": "MINERU_FILE_EXTENSIONS",
"rag.mineru_params": "MINERU_PARAMS",
"rag.mistral_ocr_api_key": "MISTRAL_OCR_API_KEY",
"rag.ollama.key": "RAG_OLLAMA_API_KEY",
"rag.ollama.url": "RAG_OLLAMA_BASE_URL",
"rag.openai_api_base_url": "RAG_OPENAI_API_BASE_URL",
"rag.openai_api_key": "RAG_OPENAI_API_KEY",
"rag.paddleocr_vl_base_url": "PADDLEOCR_VL_BASE_URL",
"rag.paddleocr_vl_token": "PADDLEOCR_VL_TOKEN",
"rag.pdf_extract_images": "PDF_EXTRACT_IMAGES",
"rag.pdf_loader_mode": "PDF_LOADER_MODE",
"rag.relevance_threshold": "RAG_RELEVANCE_THRESHOLD",
"rag.reranking_batch_size": "RAG_RERANKING_BATCH_SIZE",
"rag.reranking_engine": "RAG_RERANKING_ENGINE",
"rag.reranking_model": "RAG_RERANKING_MODEL",
"rag.template": "RAG_TEMPLATE",
"rag.text_splitter": "RAG_TEXT_SPLITTER",
"rag.tika_server_url": "TIKA_SERVER_URL",
"rag.tiktoken_encoding_name": "TIKTOKEN_ENCODING_NAME",
"rag.top_k": "RAG_TOP_K",
"rag.top_k_reranker": "RAG_TOP_K_RERANKER",
# RAG — Web
"rag.web.fetch.max_content_length": "WEB_FETCH_MAX_CONTENT_LENGTH",
"rag.web.loader.concurrent_requests": "WEB_LOADER_CONCURRENT_REQUESTS",
"rag.web.loader.engine": "WEB_LOADER_ENGINE",
"rag.web.loader.external_web_loader_api_key": "EXTERNAL_WEB_LOADER_API_KEY",
"rag.web.loader.external_web_loader_url": "EXTERNAL_WEB_LOADER_URL",
"rag.web.loader.firecrawl_api_key": "FIRECRAWL_API_KEY",
"rag.web.loader.firecrawl_api_url": "FIRECRAWL_API_BASE_URL",
"rag.web.loader.firecrawl_timeout": "FIRECRAWL_TIMEOUT",
"rag.web.loader.playwright_timeout": "PLAYWRIGHT_TIMEOUT",
"rag.web.loader.playwright_ws_url": "PLAYWRIGHT_WS_URL",
"rag.web.loader.ssl_verification": "ENABLE_WEB_LOADER_SSL_VERIFICATION",
"rag.web.loader.timeout": "WEB_LOADER_TIMEOUT",
"rag.web.search.azure_ai_search_api_key": "AZURE_AI_SEARCH_API_KEY",
"rag.web.search.azure_ai_search_endpoint": "AZURE_AI_SEARCH_ENDPOINT",
"rag.web.search.azure_ai_search_index_name": "AZURE_AI_SEARCH_INDEX_NAME",
"rag.web.search.bing_search_v7_endpoint": "BING_SEARCH_V7_ENDPOINT",
"rag.web.search.bing_search_v7_subscription_key": "BING_SEARCH_V7_SUBSCRIPTION_KEY",
"rag.web.search.bocha_search_api_key": "BOCHA_SEARCH_API_KEY",
"rag.web.search.brave_search_api_key": "BRAVE_SEARCH_API_KEY",
"rag.web.search.brave_search_context_tokens": "BRAVE_SEARCH_CONTEXT_TOKENS",
"rag.web.search.bypass_embedding_and_retrieval": "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL",
"rag.web.search.bypass_web_loader": "BYPASS_WEB_SEARCH_WEB_LOADER",
"rag.web.search.concurrent_requests": "WEB_SEARCH_CONCURRENT_REQUESTS",
"rag.web.search.ddgs_backend": "DDGS_BACKEND",
"rag.web.search.domain.filter_list": "WEB_SEARCH_DOMAIN_FILTER_LIST",
"rag.web.search.enable": "ENABLE_WEB_SEARCH",
"rag.web.search.engine": "WEB_SEARCH_ENGINE",
"rag.web.search.exa_api_key": "EXA_API_KEY",
"rag.web.search.external_web_search_api_key": "EXTERNAL_WEB_SEARCH_API_KEY",
"rag.web.search.external_web_search_url": "EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.google_pse_api_key": "GOOGLE_PSE_API_KEY",
"rag.web.search.google_pse_engine_id": "GOOGLE_PSE_ENGINE_ID",
"rag.web.search.jina_api_base_url": "JINA_API_BASE_URL",
"rag.web.search.jina_api_key": "JINA_API_KEY",
"rag.web.search.kagi_search_api_key": "KAGI_SEARCH_API_KEY",
"rag.web.search.linkup_api_key": "LINKUP_API_KEY",
"rag.web.search.linkup_search_params": "LINKUP_SEARCH_PARAMS",
"rag.web.search.mojeek_search_api_key": "MOJEEK_SEARCH_API_KEY",
"rag.web.search.ollama_cloud_api_key": "OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
"rag.web.search.perplexity_api_key": "PERPLEXITY_API_KEY",
"rag.web.search.perplexity_model": "PERPLEXITY_MODEL",
"rag.web.search.perplexity_search_api_url": "PERPLEXITY_SEARCH_API_URL",
"rag.web.search.perplexity_search_context_usage": "PERPLEXITY_SEARCH_CONTEXT_USAGE",
"rag.web.search.result_count": "WEB_SEARCH_RESULT_COUNT",
"rag.web.search.searchapi_api_key": "SEARCHAPI_API_KEY",
"rag.web.search.searchapi_engine": "SEARCHAPI_ENGINE",
"rag.web.search.searxng_language": "SEARXNG_LANGUAGE",
"rag.web.search.searxng_query_url": "SEARXNG_QUERY_URL",
"rag.web.search.serpapi_api_key": "SERPAPI_API_KEY",
"rag.web.search.serpapi_engine": "SERPAPI_ENGINE",
"rag.web.search.serper_api_key": "SERPER_API_KEY",
"rag.web.search.serply_api_key": "SERPLY_API_KEY",
"rag.web.search.serpstack_api_key": "SERPSTACK_API_KEY",
"rag.web.search.serpstack_https": "SERPSTACK_HTTPS",
"rag.web.search.sougou_api_sid": "SOUGOU_API_SID",
"rag.web.search.sougou_api_sk": "SOUGOU_API_SK",
"rag.web.search.tavily_api_key": "TAVILY_API_KEY",
"rag.web.search.tavily_extract_depth": "TAVILY_EXTRACT_DEPTH",
"rag.web.search.trust_env": "WEB_SEARCH_TRUST_ENV",
"rag.web.search.yacy_password": "YACY_PASSWORD",
"rag.web.search.yacy_query_url": "YACY_QUERY_URL",
"rag.web.search.yacy_username": "YACY_USERNAME",
"rag.web.search.yandex_web_search_api_key": "YANDEX_WEB_SEARCH_API_KEY",
"rag.web.search.yandex_web_search_config": "YANDEX_WEB_SEARCH_CONFIG",
"rag.web.search.yandex_web_search_url": "YANDEX_WEB_SEARCH_URL",
"rag.web.search.youcom_api_key": "YOUCOM_API_KEY",
"rag.youtube_loader_language": "YOUTUBE_LOADER_LANGUAGE",
"rag.youtube_loader_proxy_url": "YOUTUBE_LOADER_PROXY_URL",
# Tasks
"task.autocomplete.enable": "ENABLE_AUTOCOMPLETE_GENERATION",
"task.autocomplete.input_max_length": "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
"task.autocomplete.prompt_template": "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE",
"task.follow_up.enable": "ENABLE_FOLLOW_UP_GENERATION",
"task.follow_up.prompt_template": "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE",
"task.image.prompt_template": "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
"task.model.default": "TASK_MODEL",
"task.model.external": "TASK_MODEL_EXTERNAL",
"task.query.prompt_template": "QUERY_GENERATION_PROMPT_TEMPLATE",
"task.query.retrieval.enable": "ENABLE_RETRIEVAL_QUERY_GENERATION",
"task.query.search.enable": "ENABLE_SEARCH_QUERY_GENERATION",
"task.tags.enable": "ENABLE_TAGS_GENERATION",
"task.tags.prompt_template": "TAGS_GENERATION_PROMPT_TEMPLATE",
"task.title.enable": "ENABLE_TITLE_GENERATION",
"task.title.prompt_template": "TITLE_GENERATION_PROMPT_TEMPLATE",
"task.tools.prompt_template": "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE",
"task.voice.prompt.enable": "ENABLE_VOICE_MODE_PROMPT",
"task.voice.prompt_template": "VOICE_MODE_PROMPT_TEMPLATE",
# Misc
"terminal_server.connections": "TERMINAL_SERVER_CONNECTIONS",
"tool_server.connections": "TOOL_SERVER_CONNECTIONS",
"ui.banners": "WEBUI_BANNERS",
"ui.default_group_id": "DEFAULT_GROUP_ID",
"ui.default_locale": "DEFAULT_LOCALE",
"ui.default_models": "DEFAULT_MODELS",
"ui.default_pinned_models": "DEFAULT_PINNED_MODELS",
"ui.default_user_role": "DEFAULT_USER_ROLE",
"ui.enable_community_sharing": "ENABLE_COMMUNITY_SHARING",
"ui.enable_login_form": "ENABLE_LOGIN_FORM",
"ui.enable_message_rating": "ENABLE_MESSAGE_RATING",
"ui.enable_password_change_form": "ENABLE_PASSWORD_CHANGE_FORM",
"ui.enable_signup": "ENABLE_SIGNUP",
"ui.enable_user_webhooks": "ENABLE_USER_WEBHOOKS",
"ui.model_order_list": "MODEL_ORDER_LIST",
"ui.pending_user_overlay_content": "PENDING_USER_OVERLAY_CONTENT",
"ui.pending_user_overlay_title": "PENDING_USER_OVERLAY_TITLE",
"ui.prompt_suggestions": "DEFAULT_PROMPT_SUGGESTIONS",
"ui.watermark": "RESPONSE_WATERMARK",
"user.permissions": "USER_PERMISSIONS",
"users.enable_status": "ENABLE_USER_STATUS",
"webhook_url": "WEBHOOK_URL",
"webui.url": "WEBUI_URL",
}
STORAGE_KEY_REWRITES = {
"oauth.refresh_token_include_scope": "oauth.refresh_token.include_scope",
"rag.openai_api_base_url": "rag.openai.api_base_url",
"rag.openai_api_key": "rag.openai.api_key",
"rag.ollama.url": "rag.ollama.base_url",
"rag.ollama.key": "rag.ollama.api_key",
"oauth.oidc.avatar_claim": "oauth.picture_claim",
"oauth.oidc.client_id": "oauth.client_id",
"oauth.oidc.client_secret": "oauth.client_secret",
"oauth.oidc.code_challenge_method": "oauth.code_challenge_method",
"oauth.oidc.email_claim": "oauth.email_claim",
"oauth.oidc.end_session_endpoint": "oauth.end_session_endpoint",
"oauth.oidc.group_claim": "oauth.group_claim",
"oauth.oidc.oauth_timeout": "oauth.timeout",
"oauth.oidc.provider_name": "oauth.provider_name",
"oauth.oidc.provider_url": "oauth.provider_url",
"oauth.oidc.redirect_uri": "oauth.redirect_uri",
"oauth.oidc.scopes": "oauth.scopes",
"oauth.oidc.sub_claim": "oauth.sub_claim",
"oauth.oidc.token_endpoint_auth_method": "oauth.token_endpoint_auth_method",
"oauth.oidc.username_claim": "oauth.username_claim",
}
LEGACY_KEY_TO_STORAGE_KEY = {
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path)
for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
}
def _walk_blob(data: dict, prefix: str = '') -> dict:
"""Recursively walk a nested dict, yielding (dot.path, value) for leaf nodes."""
result = {}
for key, value in data.items():
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
if isinstance(value, dict):
result.update(_walk_blob(value, path))
else:
result[path] = value
return result
def upgrade() -> None:
"""Reshape config from single-row JSON blob to per-key rows."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
has_old_config = {'id', 'data'}.issubset(config_columns)
has_new_config = {'key', 'value'}.issubset(config_columns)
# Ad-hoc table reference for reading the old schema
old_config = sa.table(
'config',
sa.column('id', sa.Integer),
sa.column('data', sa.JSON),
)
# 1. Read existing blob
blob_data = {}
if has_old_config:
try:
result = conn.execute(
sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1)
)
row = result.fetchone()
if row and row[0]:
raw = row[0]
blob_data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
pass # Table might be partially migrated or empty
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
if has_old_config:
if 'config_old' in table_names:
op.drop_table('config_old')
op.rename_table('config', 'config_old')
# 3. Create new per-key table
new_config = (
sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON()),
sa.column('updated_at', sa.BigInteger),
)
if has_new_config
else op.create_table(
'config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# 4. Flatten blob and insert per-key rows
if blob_data:
flat = _walk_blob(blob_data)
# Keep stable dot-notation paths as the database keys.
# Known legacy env-style keys are rewritten to their dotted keys; unknown
# keys are still copied so custom/future config is not silently lost.
rows = {}
for blob_path, value in flat.items():
if blob_path in BLOB_PATH_TO_KEY:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
else:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
if storage_key not in rows:
rows[storage_key] = value
# Batch insert via SQLAlchemy table reference
if rows:
now = int(time.time())
op.bulk_insert(
new_config,
[
{'key': k, 'value': v, 'updated_at': now}
for k, v in rows.items()
],
)
def downgrade() -> None:
"""Restore preserved old single-row config table when available."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
if 'config_old' in table_names:
if 'config' in table_names:
op.drop_table('config')
op.rename_table('config_old', 'config')
return
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
has_per_key_config = {'key', 'value'}.issubset(config_columns)
blob_data = {}
if has_per_key_config:
config = sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON),
)
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
blob_data[key] = json.loads(value) if isinstance(value, str) else value
op.drop_table('config')
if 'config' in table_names and not has_per_key_config:
return
old_config = op.create_table(
'config',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
if blob_data:
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])
@@ -0,0 +1,74 @@
"""add missing primary keys to legacy peewee tables
Revision ID: 461111b60977
Revises: 3c9b0ca343fd
Create Date: 2026-05-14 04:38:14.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '461111b60977'
down_revision: Union[str, None] = '3c9b0ca343fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Tables bootstrapped by the old Peewee migration layer that may have
# UNIQUE(id) but no PRIMARY KEY constraint. Fresh Alembic installs
# already have correct PKs from 7e5b5dc7342b_init.py.
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
LEGACY_TABLES = {
'auth': ['id'],
'chat': ['id'],
'chatidtag': ['id'],
'document': ['id'],
'file': ['id'],
'function': ['id'],
'memory': ['id'],
'model': ['id'],
'prompt': ['id'],
'tag': ['id', 'user_id'],
'tool': ['id'],
'user': ['id'],
}
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
for table_name, pk_columns in LEGACY_TABLES.items():
if table_name not in existing_tables:
continue
pk = inspector.get_pk_constraint(table_name)
pk_cols = pk.get('constrained_columns', [])
# Already has the correct PK — nothing to do
if sorted(pk_cols) == sorted(pk_columns):
continue
# Check that all PK columns exist
columns = {c['name'] for c in inspector.get_columns(table_name)}
if not all(c in columns for c in pk_columns):
continue
print(f"Promoting UNIQUE(id) -> PRIMARY KEY({', '.join(pk_columns)}) for '{table_name}'")
conn.execute(sa.text(f'DROP TABLE IF EXISTS _alembic_tmp_{table_name}'))
with op.batch_alter_table(table_name) as batch_op:
# Drop existing PK if any (e.g. on wrong column)
if pk_cols and pk.get('name'):
batch_op.drop_constraint(pk['name'], type_='primary')
batch_op.create_primary_key(f'pk_{table_name}', pk_columns)
def downgrade() -> None:
# Downgrade is a no-op — we don't want to remove PKs
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '4ace53fd72c8'
down_revision = 'af906e964978'
@@ -16,6 +16,18 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {c['name']: c for c in inspector.get_columns('folder')}
created_at_col = columns.get('created_at')
if not created_at_col:
return
# Only convert if still DateTime — skip if already BigInteger
if isinstance(created_at_col['type'], sa.BigInteger):
return
# Perform safe alterations using batch operation
with op.batch_alter_table('folder', schema=None) as batch_op:
# Step 1: Remove server defaults for created_at and updated_at
@@ -48,20 +60,24 @@ def upgrade():
def downgrade():
# Downgrade: Convert columns back to DateTime and restore defaults
# Convert columns back to DateTime and restore defaults. Mirrors the
# upgrade's postgresql_using cast — without it, Postgres can't
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
with op.batch_alter_table('folder', schema=None) as batch_op:
batch_op.alter_column(
'created_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(), # Restoring server default on downgrade
server_default=sa.func.now(),
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
)
batch_op.alter_column(
'updated_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(), # Restoring server default on downgrade
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
server_default=sa.func.now(),
onupdate=sa.func.now(),
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
)
@@ -8,10 +8,9 @@ Create Date: 2026-05-09 04:29:27.651341
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4de81c2a3af1'
@@ -20,46 +19,55 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import uuid
import time
from sqlalchemy import select, update, insert
from sqlalchemy.sql import table, column
import uuid
from sqlalchemy import insert, select, update
from sqlalchemy.sql import column, table
def upgrade() -> None:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
if 'pinned_note' not in existing_tables:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
)
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
for note in notes
],
)
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
def downgrade() -> None:
@@ -8,9 +8,8 @@ Create Date: 2026-04-19 16:20:58.162045
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '56359461a091'
@@ -19,58 +18,86 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _index_exists(inspector, index_name, table_name):
"""Check if an index already exists on the given table."""
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
if 'calendar' not in tables:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
inspector.clear_cache()
if 'calendar' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
if 'calendar_event' not in tables:
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
inspector.clear_cache()
if 'calendar_event' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
if 'calendar_event_attendee' not in tables:
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
inspector.clear_cache()
if 'calendar_event_attendee' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
op.create_index(
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
)
def downgrade() -> None:
@@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '57c599a3cb57'
down_revision = '922e7a387820'
@@ -16,30 +16,36 @@ depends_on = None
def upgrade():
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'channel' not in existing_tables:
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'message' not in existing_tables:
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '6283dc0e4d8d'
@@ -20,31 +20,38 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'channel_file' not in existing_tables:
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
'channel_id', 'file_id', name='uq_channel_file_channel_file'
), # prevent duplicate entries
)
def downgrade() -> None:
@@ -6,11 +6,12 @@ Create Date: 2024-10-01 14:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, select, table
revision = '6a39f3d8e55c'
down_revision = 'c0fbf31ca0db'
branch_labels = None
@@ -18,62 +19,67 @@ depends_on = None
def upgrade():
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = op.get_bind().execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
if 'knowledge' not in existing_tables:
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# Insert data into knowledge table from document table
for doc in documents:
op.get_bind().execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = conn.execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
)
)
# Insert data into knowledge table from document table
for doc in documents:
conn.execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp,
)
)
def downgrade():
op.drop_table('knowledge')
@@ -6,8 +6,8 @@ Create Date: 2024-12-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '7826ab40b532'
down_revision = '57c599a3cb57'
@@ -16,10 +16,15 @@ depends_on = None
def upgrade():
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
if 'access_control' not in file_cols:
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():
@@ -1,44 +1,34 @@
"""init
Revision ID: 7e5b5dc7342b
Revises:
Create Date: 2024-06-24 13:15:33.808998
"""
from typing import Sequence, Union
# Initial bootstrap migration version.
# Revision ID: 7e5b5dc7342b
# Revises: (none)
# Created on: 2024-06-24 13:15:33.808998
from __future__ import annotations
from typing import Sequence
import open_webui.internal.db # noqa: F401
import sqlalchemy as sa
from alembic import op
import open_webui.internal.db
from open_webui.internal.db import JSONField
from open_webui.migrations.util import get_existing_tables
# revision identifiers, used by Alembic.
revision: str = '7e5b5dc7342b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
# ### commands auto generated by Alembic - please adjust! ###
if 'auth' not in existing_tables:
op.create_table(
'auth',
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Initial schema table declarations
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
(
'auth',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('password', sa.Text(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'chat' not in existing_tables:
op.create_table(
'chat',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'chat',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@@ -47,24 +37,23 @@ def upgrade() -> None:
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('share_id', sa.Text(), nullable=True),
sa.Column('archived', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('share_id'),
)
if 'chatidtag' not in existing_tables:
op.create_table(
'chatidtag',
],
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
),
(
'chatidtag',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('tag_name', sa.String(), nullable=True),
sa.Column('chat_id', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'document' not in existing_tables:
op.create_table(
'document',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'document',
[
sa.Column('collection_name', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@@ -72,24 +61,23 @@ def upgrade() -> None:
sa.Column('content', sa.Text(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('collection_name'),
sa.UniqueConstraint('name'),
)
if 'file' not in existing_tables:
op.create_table(
'file',
],
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
),
(
'file',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('filename', sa.Text(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'function' not in existing_tables:
op.create_table(
'function',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'function',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@@ -101,23 +89,23 @@ def upgrade() -> None:
sa.Column('is_global', sa.Boolean(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'memory' not in existing_tables:
op.create_table(
'memory',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'memory',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'model' not in existing_tables:
op.create_table(
'model',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'model',
[
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('base_model_id', sa.Text(), nullable=True),
@@ -126,33 +114,33 @@ def upgrade() -> None:
sa.Column('meta', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'prompt' not in existing_tables:
op.create_table(
'prompt',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'prompt',
[
sa.Column('command', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('command'),
)
if 'tag' not in existing_tables:
op.create_table(
'tag',
],
[sa.PrimaryKeyConstraint('command')],
),
(
'tag',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('data', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'tool' not in existing_tables:
op.create_table(
'tool',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'tool',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@@ -162,12 +150,12 @@ def upgrade() -> None:
sa.Column('valves', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'user' not in existing_tables:
op.create_table(
'user',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'user',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
@@ -180,25 +168,25 @@ def upgrade() -> None:
sa.Column('settings', JSONField(), nullable=True),
sa.Column('info', JSONField(), nullable=True),
sa.Column('oauth_sub', sa.Text(), nullable=True),
],
[
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key'),
sa.UniqueConstraint('oauth_sub'),
)
# ### end Alembic commands ###
],
),
]
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
op.drop_table('tool')
op.drop_table('tag')
op.drop_table('prompt')
op.drop_table('model')
op.drop_table('memory')
op.drop_table('function')
op.drop_table('file')
op.drop_table('document')
op.drop_table('chatidtag')
op.drop_table('chat')
op.drop_table('auth')
# ### end Alembic commands ###
# --- migration execution ---
def upgrade() -> None: # deploy initial schema tables
existing_tables = set(get_existing_tables())
for name, columns, constraints in _INITIAL_TABLES:
if name not in existing_tables:
op.create_table(name, *columns, *constraints)
# --- rollback function ---
def downgrade() -> None: # rollback initial schema tables
for table_name, _, _ in reversed(_INITIAL_TABLES):
op.drop_table(table_name)
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 16:07:58.001282
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '81cc2ce44d79'
@@ -20,20 +20,27 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Add message_id column to channel_file table
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
if 'message_id' not in cf_cols:
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
)
)
)
# Add data column to knowledge table
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'data' not in k_cols:
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
def downgrade() -> None:
@@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
import time
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
log = logging.getLogger(__name__)
@@ -56,6 +56,13 @@ def _flush_batch(conn, table, batch):
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'chat_message' in existing_tables:
return # Already created — skip everything
# Step 1: Create table
op.create_table(
'chat_message',
@@ -85,8 +92,6 @@ def upgrade() -> None:
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
# Step 2: Backfill from existing chats
conn = op.get_bind()
chat_table = sa.table(
'chat',
sa.column('id', sa.Text()),
@@ -8,9 +8,9 @@ Create Date: 2025-11-30 06:33:38.790341
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '90ef40d4714e'
@@ -20,42 +20,53 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Update 'channel' table
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'is_private' not in channel_cols:
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
if 'archived_at' not in channel_cols:
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
if 'archived_by' not in channel_cols:
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
if 'deleted_at' not in channel_cols:
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
if 'deleted_by' not in channel_cols:
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
if 'updated_by' not in channel_cols:
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
# Update 'channel_member' table
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'role' not in cm_cols:
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
if 'invited_by' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
if 'invited_at' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
# Create 'channel_webhook' table
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
pass
if 'channel_webhook' not in existing_tables:
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
def downgrade() -> None:
@@ -74,5 +85,3 @@ def downgrade() -> None:
# Drop 'channel_webhook' table
op.drop_table('channel_webhook')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-11-14 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '922e7a387820'
down_revision = '4ace53fd72c8'
@@ -16,70 +16,55 @@ depends_on = None
def upgrade():
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group' not in existing_tables:
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# Add 'access_control' column to 'model' table
op.add_column(
'model',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'is_active' column to 'model' table
op.add_column(
'model',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
server_default=sa.sql.expression.true(),
),
)
model_cols = {c['name'] for c in inspector.get_columns('model')}
if 'access_control' not in model_cols:
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
if 'is_active' not in model_cols:
op.add_column(
'model',
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
)
# Add 'access_control' column to 'knowledge' table
op.add_column(
'knowledge',
sa.Column('access_control', sa.JSON(), nullable=True),
)
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'access_control' not in knowledge_cols:
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
# Add 'access_control' column to 'prompt' table
op.add_column(
'prompt',
sa.Column('access_control', sa.JSON(), nullable=True),
)
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'access_control' not in prompt_cols:
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
# Add 'access_control' column to 'tools' table
op.add_column(
'tool',
sa.Column('access_control', sa.JSON(), nullable=True),
)
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
if 'access_control' not in tool_cols:
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
def downgrade():
op.drop_table('group')
# Drop 'access_control' column from 'model' table
op.drop_column('model', 'access_control')
# Drop 'is_active' column from 'model' table
op.drop_column('model', 'is_active')
# Drop 'access_control' column from 'knowledge' table
op.drop_column('knowledge', 'access_control')
# Drop 'access_control' column from 'prompt' table
op.drop_column('prompt', 'access_control')
# Drop 'access_control' column from 'tools' table
op.drop_column('tool', 'access_control')
@@ -6,8 +6,8 @@ Create Date: 2025-05-03 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '9f0c9cd09105'
down_revision = '3781e22d8b01'
@@ -16,17 +16,22 @@ depends_on = None
def upgrade():
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'note' not in existing_tables:
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
@@ -6,6 +6,7 @@ Create Date: 2025-09-15 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = 'a0b1c2d3e4f5'
@@ -15,7 +16,12 @@ depends_on = None
def upgrade():
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {idx['name'] for idx in inspector.get_indexes('memory')}
if 'ix_memory_user_id' not in existing_indexes:
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
def downgrade():
@@ -8,9 +8,8 @@ Create Date: 2026-02-11 09:30:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
from open_webui.migrations.util import get_existing_tables
revision: str = 'a1b2c3d4e5f6'
@@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
@@ -19,8 +19,14 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'tasks' not in columns:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
if 'summary' not in columns:
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:
@@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a5c220713937'
@@ -19,16 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
msg_cols = {c['name'] for c in inspector.get_columns('message')}
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
pass
if 'reply_to_id' not in msg_cols:
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column('message', 'reply_to_id')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-20 17:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# Revision identifiers, used by Alembic.
revision = 'af906e964978'
@@ -17,23 +17,28 @@ depends_on = None
def upgrade():
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'feedback' not in existing_tables:
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
def downgrade():
@@ -6,15 +6,13 @@ Create Date: 2025-11-28 04:55:31.737538
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import json
import time
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b10670c03dd5'
@@ -22,20 +20,49 @@ down_revision: Union[str, None] = '2f1211949ecc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# ── Ad-hoc table references for Core DML ─────────────────────────────────
# These are lightweight table() / column() references used only inside this
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
# anything on disk.
_user = sa.table(
'user',
sa.column('id', sa.Text),
sa.column('oauth_sub', sa.Text),
sa.column('oauth', sa.JSON),
sa.column('api_key', sa.Text),
sa.column('info', sa.Text),
sa.column('settings', sa.Text),
)
_api_key = sa.table(
'api_key',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('key', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
"""
SQLite requires manual removal of any indexes referencing a column
before ALTER TABLE ... DROP COLUMN can succeed.
SQLite requires manual removal of any user-created indexes referencing
a column before ALTER TABLE ... DROP COLUMN can succeed.
NOTE: PRAGMAs have no Core equivalent raw text is unavoidable here.
"""
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
for idx in indexes:
index_name = idx[1] # index name
# Get indexed columns
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
# cannot be dropped directly and will disappear when the column is
# removed via batch_alter_table.
if index_name.startswith('sqlite_autoindex_'):
continue
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
indexed_cols = [row[2] for row in idx_info] # col names
indexed_cols = [row[2] for row in idx_info]
if column_name in indexed_cols:
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
@@ -44,33 +71,31 @@ def _convert_column_to_json(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
t_json = sa.column(f'{column}_json', sa.JSON)
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == 'sqlite':
# 1. Add temporary column
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
for row in rows:
uid, raw = row
for uid, raw in rows:
if raw is None:
parsed = None
else:
try:
parsed = json.loads(raw)
except Exception:
parsed = None # fallback safe behavior
parsed = None
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
sa.update(sa.table(table, sa.column('id'), t_json))
.where(sa.column('id') == uid)
.values({f'{column}_json': json.dumps(parsed) if parsed else None})
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f'{column}_json', new_column_name=column)
else:
@@ -87,15 +112,19 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column))
t_text = sa.column(f'{column}_text', sa.Text)
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
for uid, raw in rows:
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{'val': json.dumps(raw) if raw else None, 'id': uid},
sa.update(sa.table(table, sa.column('id'), t_text))
.where(sa.column('id') == uid)
.values({f'{column}_text': json.dumps(raw) if raw else None})
)
op.drop_column(table, column)
@@ -111,88 +140,93 @@ def _convert_column_to_text(table: str, column: str):
def upgrade() -> None:
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'settings')
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
user_columns = {c['name'] for c in inspector.get_columns('user')}
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if '@' in oauth_sub:
provider, sub = oauth_sub.split('@', 1)
else:
provider, sub = 'oidc', oauth_sub
# ── Add new columns (idempotent) ──────────────────────────────────
for col_name, col_type in [
('profile_banner_image_url', sa.Text()),
('timezone', sa.String()),
('presence_state', sa.String()),
('status_emoji', sa.String()),
('status_message', sa.Text()),
('status_expires_at', sa.BigInteger()),
('oauth', sa.JSON()),
]:
if col_name not in user_columns:
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
oauth_json = json.dumps({provider: {'sub': sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{'oauth': oauth_json, 'id': uid},
)
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
if isinstance(user_col_types.get('info'), sa.Text):
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
if isinstance(user_col_types.get('settings'), sa.Text):
_convert_column_to_json('user', 'settings')
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
now = int(time.time())
# ── Create api_key table (idempotent) ─────────────────────────────
if 'api_key' not in existing_tables:
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text("""
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""),
{
'id': f'key_{uid}',
'user_id': uid,
'key': api_key,
'created_at': now,
'updated_at': now,
},
)
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
if 'oauth_sub' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
if conn.dialect.name == 'sqlite':
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
for uid, oauth_sub in rows:
if oauth_sub:
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
conn.execute(
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
)
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('api_key')
batch_op.drop_column('oauth_sub')
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
now = int(time.time())
for uid, key_val in rows:
if key_val:
conn.execute(
sa.insert(_api_key).values(
id=f'key_{uid}',
user_id=uid,
key=key_val,
created_at=now,
updated_at=now,
)
)
# ── Drop legacy columns (idempotent) ──────────────────────────────
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
if cols_to_drop:
if conn.dialect.name == 'sqlite':
for col in cols_to_drop:
_drop_sqlite_indexes_for_column('user', col, conn)
with op.batch_alter_table('user') as batch_op:
for col in cols_to_drop:
batch_op.drop_column(col)
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
conn = op.get_bind()
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
for uid, oauth in users:
for uid, oauth in rows:
try:
data = json.loads(oauth)
provider = list(data.keys())[0]
@@ -201,25 +235,17 @@ def downgrade() -> None:
except Exception:
oauth_sub = None
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{'oauth_sub': oauth_sub, 'id': uid},
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
op.drop_column('user', 'oauth')
# --- 2. Restore api_key field ---
# --- Restore api_key field ---
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
# Restore values from api_key
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
for uid, key in keys:
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{'key': key, 'id': uid},
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
# Drop new table
op.drop_table('api_key')
with op.batch_alter_table('user') as batch_op:
@@ -8,8 +8,8 @@ Create Date: 2026-02-13 14:19:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
@@ -19,7 +19,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'scim' not in user_cols:
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
def downgrade() -> None:
@@ -6,9 +6,8 @@ Create Date: 2026-04-01 04:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
@@ -18,9 +17,14 @@ depends_on = None
def upgrade():
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'last_read_at' not in columns:
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
def downgrade():
@@ -19,10 +19,17 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
if 'hash' not in file_cols:
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
if 'data' not in file_cols:
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
if 'updated_at' not in file_cols:
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
def downgrade():
@@ -9,8 +9,8 @@ Create Date: 2026-04-16 23:00:00.000000
import time
import uuid
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
@@ -61,18 +61,21 @@ access_grant_t = sa.table(
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 1. Create shared_chat table (idempotent)
if 'shared_chat' not in tables:
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
@@ -96,31 +99,51 @@ def upgrade():
if not original:
continue
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Check if shared_chat record already exists (idempotent)
existing_shared = conn.execute(
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
).fetchone()
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
if not existing_shared:
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Check if access_grant record already exists (idempotent)
existing_grant = conn.execute(
sa.select(access_grant_t.c.id).where(
sa.and_(
access_grant_t.c.resource_type == 'shared_chat',
access_grant_t.c.resource_id == original_chat_id,
access_grant_t.c.principal_type == 'user',
access_grant_t.c.principal_id == '*',
access_grant_t.c.permission == 'read',
)
)
).fetchone()
if not existing_grant:
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
)
# 3. Clean up old phantom rows
conn.execute(
@@ -6,11 +6,12 @@ Create Date: 2024-10-20 17:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
import sqlalchemy as sa
from alembic import op
from sqlalchemy import JSON, String, Text, and_
from sqlalchemy.sql import column, table
revision = 'c29facfe716b'
down_revision = 'c69f45358db4'
@@ -19,8 +20,13 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# 1. Add the `path` column to the "file" table.
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
if 'path' not in file_cols:
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
# Use Alembic's default batch_op for dialect compatibility.
@@ -8,8 +8,8 @@ Create Date: 2025-12-21 20:27:41.694897
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c440947495f3'
@@ -19,36 +19,39 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'chat_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'chat_id',
sa.Text(),
sa.ForeignKey('chat.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_chat_file_chat_id', 'chat_id'),
sa.Index('ix_chat_file_file_id', 'file_id'),
sa.Index('ix_chat_file_message_id', 'message_id'),
sa.Index('ix_chat_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
)
pass
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'chat_file' not in existing_tables:
op.create_table(
'chat_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'chat_id',
sa.Text(),
sa.ForeignKey('chat.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_chat_file_chat_id', 'chat_id'),
sa.Index('ix_chat_file_file_id', 'file_id'),
sa.Index('ix_chat_file_message_id', 'message_id'),
sa.Index('ix_chat_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table('chat_file')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-16 02:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'c69f45358db4'
down_revision = '3ab32c4b8f59'
@@ -16,30 +16,37 @@ depends_on = None
def upgrade():
op.create_table(
'folder',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('items', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint('id', 'user_id'),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
op.add_column(
'chat',
sa.Column('folder_id', sa.Text(), nullable=True),
)
if 'folder' not in existing_tables:
op.create_table(
'folder',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('items', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint('id', 'user_id'),
)
chat_cols = {c['name'] for c in inspector.get_columns('chat')}
if 'folder_id' not in chat_cols:
op.add_column(
'chat',
sa.Column('folder_id', sa.Text(), nullable=True),
)
def downgrade():
@@ -1,9 +1,8 @@
"""Add config table
"""Add config table.
Revision ID: ca81bd47c050
Revises: 7e5b5dc7342b
Create Date: 2024-08-25 15:26:35.241684
"""
from typing import Sequence, Union
@@ -11,29 +10,40 @@ from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'ca81bd47c050'
down_revision: Union[str, None] = '7e5b5dc7342b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
op.create_table(
'config',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer, nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
)
def upgrade() -> None:
"""Create a key-value config table with versioning."""
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'config' not in existing_tables:
op.create_table(
'config',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer, nullable=False),
sa.Column(
'created_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
)
def downgrade():
def downgrade() -> None:
"""Drop the config table."""
op.drop_table('config')
@@ -6,8 +6,8 @@ Create Date: 2025-07-13 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'd31026856c01'
down_revision = '9f0c9cd09105'
@@ -16,7 +16,12 @@ depends_on = None
def upgrade():
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
folder_cols = {c['name'] for c in inspector.get_columns('folder')}
if 'data' not in folder_cols:
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
def downgrade():
@@ -7,8 +7,8 @@ Create Date: 2026-03-30
from typing import Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'a3dd5bedd151'
@@ -16,36 +16,56 @@ branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
def _index_exists(inspector, index_name, table_name):
"""Check if an index already exists on the given table (works for both SQLite and PostgreSQL)."""
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'automation' not in tables:
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
inspector.clear_cache()
if 'automation' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_automation_next_run', 'automation'):
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
if 'automation_run' not in tables:
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
inspector.clear_cache()
if 'automation_run' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_automation_run_automation_id', 'automation_run'):
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def downgrade():
@@ -6,8 +6,8 @@ Create Date: 2026-04-14 22:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'e1f2a3b4c5d6'
down_revision = 'b7c8d9e0f1a2'
@@ -16,7 +16,12 @@ depends_on = None
def upgrade():
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('note')]
if 'is_pinned' not in columns:
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
def downgrade():
@@ -11,13 +11,12 @@ Access control semantics:
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
"""
from typing import Sequence, Union
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
from open_webui.migrations.util import get_existing_tables
revision: str = 'f1e2d3c4b5a6'
@@ -81,13 +80,18 @@ def upgrade() -> None:
if table_name not in existing_tables:
continue
# Query all rows
try:
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
rows = result.fetchall()
except Exception:
# Check if access_control and id columns exist (may already be dropped on re-run,
# or table may have been rebuilt without id during intermediate migration states)
insp = sa.inspect(conn)
insp.clear_cache() # Ensure fresh metadata after prior migrations that rebuild tables
table_cols = {c['name'] for c in insp.get_columns(table_name)}
if 'access_control' not in table_cols or 'id' not in table_cols:
continue
# Query all rows
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
rows = result.fetchall()
for row in rows:
resource_id = row[0]
access_control_json = row[1]
@@ -208,15 +212,15 @@ def upgrade() -> None:
except Exception:
pass
# Drop access_control columns from resource tables
# Drop access_control columns from resource tables (only if column still exists)
inspector = sa.inspect(conn)
for table_name, _ in resource_tables:
if table_name not in existing_tables:
continue
try:
cols = {c['name'] for c in inspector.get_columns(table_name)}
if 'access_control' in cols:
with op.batch_alter_table(table_name) as batch:
batch.drop_column('access_control')
except Exception:
pass
def downgrade() -> None:
+3 -5
View File
@@ -3,13 +3,11 @@ import time
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, and_, delete, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -623,8 +621,8 @@ class AccessGrantsTable:
Get all users who have the specified permission on a resource.
Returns a list of UserModel instances.
"""
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel, Users
async with get_async_db_context(db) as db:
result = await db.execute(
+145 -118
View File
@@ -1,50 +1,56 @@
"""Auth credential models and data-access layer."""
from __future__ import annotations
import logging
import uuid
from typing import Optional
from sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
####################
# DB MODEL
####################
# Pre-computed hash verified on signin paths that lack a real credential
# (unknown user, inactive account) so response timing cannot reveal
# whether an account exists (CWE-208).
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
class Auth(Base):
class Auth(Base): # credential ↔ user linkage
"""Maps a user ID to an email/password pair with an active flag."""
__tablename__ = 'auth'
id = Column(String, primary_key=True, unique=True)
email = Column(String)
password = Column(Text)
active = Column(Boolean)
id = Column(String, primary_key=True, unique=True) # mirrors User.id
email = Column(String) # login address, kept in sync with User.email
password = Column(Text) # argon2 / bcrypt hash
active = Column(Boolean) # account soft-disable toggle
class AuthModel(BaseModel):
"""Pydantic mirror of the ``auth`` table row."""
id: str
email: str
password: str
active: bool = True
####################
# Forms
####################
class Token(BaseModel):
"""JWT bearer-token response wrapper."""
token: str
token_type: str
class ApiKey(BaseModel):
api_key: Optional[str] = None
api_key: str | None = None
class SigninResponse(Token, UserProfileImageResponse):
@@ -74,21 +80,26 @@ class SignupForm(BaseModel):
name: str
email: str
password: str
profile_image_url: Optional[str] = '/user.png'
profile_image_url: str | None = '/user.png'
@field_validator('profile_image_url')
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
def check_profile_image_url(cls, v: str | None) -> str | None:
if v is not None:
return validate_profile_image_url(v)
return v
class AddUserForm(SignupForm):
role: Optional[str] = 'pending'
role: str | None = 'pending'
# --- data-access layer ---
class AuthsTable:
"""Provides CRUD operations for the Auth ↔ User lifecycle."""
async def insert_new_auth(
self,
email: str,
@@ -96,117 +107,133 @@ class AuthsTable:
name: str,
profile_image_url: str = '/user.png',
role: str = 'pending',
oauth: Optional[dict] = None,
db: Optional[AsyncSession] = None,
) -> Optional[UserModel]:
async with get_async_db_context(db) as db:
oauth: dict | None = None,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Create an Auth + User pair inside a single transaction."""
async with get_async_db_context(db) as session:
log.info('insert_new_auth')
id = str(uuid.uuid4())
new_id = str(uuid.uuid4())
auth = AuthModel(**{'id': id, 'email': email, 'password': password, 'active': True})
result = Auth(**auth.model_dump())
db.add(result)
credential = Auth(
id=new_id,
email=email,
password=password,
active=True,
)
session.add(credential)
user = await Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db)
await db.commit()
await db.refresh(result)
if result and user:
return user
else:
return None
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
# persist both records and reload generated defaults
await session.commit()
await session.refresh(credential)
return created_user if credential and created_user else None
async def authenticate_user(
self, email: str, verify_password: callable, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f'authenticate_user: {email}')
user = await Users.get_user_by_email(email, db=db)
if not user:
return None
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Auth).filter_by(id=user.id, active=True))
auth = result.scalars().first()
if auth:
if verify_password(auth.password):
return user
else:
return None
else:
return None
except Exception:
return None
self,
email: str,
verify_password: callable,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Verify email + password credentials and return the matching user."""
log.info('authenticate_user: %s', email)
resolved = await Users.get_user_by_email(email, db=db)
if not resolved:
verify_password(PLACEHOLDER_HASH)
return
# load the credential row and verify the password hash
async with get_async_db_context(db) as session:
credential = await session.get(Auth, resolved.id)
if not credential or not credential.active:
verify_password(PLACEHOLDER_HASH)
return
if not verify_password(credential.password):
return
return resolved
async def authenticate_user_by_api_key(
self, api_key: str, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f'authenticate_user_by_api_key')
# if no api_key, return None
self,
api_key: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up the user that owns the given API key."""
log.info('authenticate_user_by_api_key')
if not api_key:
return None
return
# delegate to the Users model for the actual lookup
return await Users.get_user_by_api_key(api_key, db=db)
try:
user = await Users.get_user_by_api_key(api_key, db=db)
return user if user else None
except Exception:
return False
async def authenticate_user_by_email(
self,
email: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Single-query auth via JOIN on Auth ↔ User, filtered by active flag."""
log.info('authenticate_user_by_email: %s', email)
# single JOIN avoids N+1 — returns (Auth, User) tuple or None
async with get_async_db_context(db) as session:
joined_query = (
select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
)
match = (await session.execute(joined_query)).first()
if not match:
return
_, found_user = match
return UserModel.model_validate(found_user)
async def authenticate_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]:
log.info(f'authenticate_user_by_email: {email}')
try:
async with get_async_db_context(db) as db:
# Single JOIN query instead of two separate queries
result = await db.execute(
select(Auth, User).join(User, Auth.id == User.id).filter(Auth.email == email, Auth.active == True)
)
row = result.first()
if row:
_, user = row
return UserModel.model_validate(user)
return None
except Exception:
return None
async def update_user_password_by_id(self, id: str, new_password: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(password=new_password))
await db.commit()
return True if result.rowcount == 1 else False
except Exception:
return False
async def update_email_by_id(self, id: str, email: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(email=email))
await db.commit()
if result.rowcount == 1:
await Users.update_user_by_id(id, {'email': email}, db=db)
return True
async def update_email_by_id(
self,
user_id: str,
email: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new email on the auth record and propagate to the user row."""
async with get_async_db_context(db) as session:
auth_row = await session.get(Auth, user_id)
if auth_row is None:
return False
except Exception:
return False
auth_row.email = email
await session.commit()
await Users.update_user_by_id(user_id, {'email': email}, db=session)
return True
# --- password modification ---
async def delete_auth_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
# Delete User
result = await Users.delete_user_by_id(id, db=db)
async def update_user_password_by_id(
self,
user_id: str,
new_password: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new password hash for an existing user."""
async with get_async_db_context(db) as session:
auth_row = await session.get(Auth, user_id)
if auth_row is None:
return False
auth_row.password = new_password
await session.commit()
return True
if result:
await db.execute(delete(Auth).filter_by(id=id))
await db.commit()
return True
else:
return False
except Exception:
return False
async def delete_auth_by_id(
self,
id: str,
db: AsyncSession | None = None,
) -> bool:
"""Remove a user and their auth credential in one transaction."""
async with get_async_db_context(db) as session:
if not await Users.delete_user_by_id(id, db=session):
return False
await session.execute(delete(Auth).where(Auth.id == id))
await session.commit()
return True
Auths = AuthsTable()
Auths = AuthsTable() # singleton — module-level instance
+4 -5
View File
@@ -1,13 +1,12 @@
import time
import logging
import time
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
+26 -20
View File
@@ -1,30 +1,29 @@
import time
import logging
import time
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
Column,
Text,
JSON,
Boolean,
BigInteger,
Index,
UniqueConstraint,
select,
or_,
exists,
func,
delete,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
Index,
Text,
UniqueConstraint,
delete,
exists,
func,
or_,
select,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -696,12 +695,19 @@ class CalendarEventTable:
self,
now_ns: int,
default_lookahead_ns: int,
grace_ns: int = 0,
db: Optional[AsyncSession] = None,
) -> list[tuple[CalendarEventModel, Optional[str]]]:
"""Events starting between now and now + lookahead, for alert processing.
Per-event lookahead is read from meta.alert_minutes (falls back to
default_lookahead_ns). Returns (event, user_timezone) pairs.
*grace_ns* widens the SQL lower bound so that events whose start_at
is up to *grace_ns* nanoseconds in the past are still fetched. This
ensures "At time of event" alerts (alert_minutes=0) are not missed
when the scheduler polls a few seconds after the event's exact start
time.
"""
from open_webui.models.users import User as UserRow
@@ -716,7 +722,7 @@ class CalendarEventTable:
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.start_at >= now_ns,
CalendarEvent.start_at >= now_ns - grace_ns,
CalendarEvent.start_at <= upper,
)
)
+12 -10
View File
@@ -4,31 +4,33 @@ import time
import uuid
from typing import Optional
from open_webui.utils.validate import validate_profile_image_url
from sqlalchemy import select, delete, update, func, case, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.groups import Groups
from open_webui.models.access_grants import (
AccessGrantModel,
AccessGrants,
)
from open_webui.models.groups import Groups
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
ForeignKey,
String,
Text,
JSON,
UniqueConstraint,
and_,
case,
delete,
func,
or_,
select,
update,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
####################
# Channel DB Schema
+67 -5
View File
@@ -3,21 +3,26 @@ import time
import uuid
from typing import Any, Optional
from sqlalchemy import select, delete, func, cast, Integer
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
ForeignKey,
Text,
JSON,
Index,
Integer,
Text,
cast,
delete,
func,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
####################
# Helpers
@@ -169,7 +174,7 @@ class ChatMessageTable:
# Update existing
if 'role' in data:
existing.role = data['role']
if 'parent_id' in data:
if 'parent_id' in data or 'parentId' in data:
existing.parent_id = data.get('parent_id') or data.get('parentId')
if 'content' in data:
existing.content = data.get('content')
@@ -391,6 +396,24 @@ class ChatMessageTable:
await db.commit()
return True
async def delete_message_ids_by_chat_id(
self,
chat_id: str,
message_ids: set[str],
db: Optional[AsyncSession] = None,
) -> bool:
"""Delete specific ``chat_message`` rows by their original message IDs."""
if not message_ids:
return True
async with get_async_db_context(db) as db:
await db.execute(
delete(ChatMessage)
.where(ChatMessage.chat_id == chat_id)
.where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids}))
)
await db.commit()
return True
# Analytics methods
async def get_message_count_by_model(
self,
@@ -419,6 +442,44 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()}
async def get_unique_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
stmt = select(
ChatMessage.model_id,
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
return {
row.model_id: {
'unique_users': row.unique_users,
'unique_chats': row.unique_chats,
}
for row in result.all()
}
async def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
@@ -579,6 +640,7 @@ class ChatMessageTable:
"""Get message counts grouped by day and model."""
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
"""Database-backed configuration with per-key storage.
Replaces the old single-row JSON blob machinery with a simple per-key model
mirroring cptr's Config.
Each config key is stored as its own row: key TEXT PK, value JSON.
Reads are direct DB lookups. Writes are explicit awaited upserts that raise on
failure (no more fire-and-forget create_task).
"""
from __future__ import annotations
import logging
import time
from typing import Any, ClassVar
from open_webui.internal.db import Base, get_async_db
from sqlalchemy import JSON, BigInteger, Column, Text, select
log = logging.getLogger(__name__)
# ── Model ────────────────────────────────────────────────────────────────────
class Config(Base):
"""Per-key config storage. Each row is one config key."""
__tablename__ = 'config'
key = Column(Text, primary_key=True)
value = Column(JSON, nullable=False)
updated_at = Column(BigInteger, nullable=True)
DEFAULTS: ClassVar[dict[str, Any]] = {}
PERSISTENT_ENABLED: ClassVar[bool] = True
OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False
# ── Class methods ────────────────────────────────────────
@classmethod
def configure(
cls,
*,
defaults: dict[str, Any] | None = None,
enable_persistent: bool = True,
enable_oauth_persistent: bool = False,
) -> None:
cls.DEFAULTS = defaults or {}
cls.PERSISTENT_ENABLED = enable_persistent
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
@classmethod
def default_value(cls, key: str, default: Any = None) -> Any:
return cls.DEFAULTS.get(key, default)
@classmethod
def persistent_enabled_for(cls, key: str) -> bool:
if not cls.PERSISTENT_ENABLED:
return False
if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED:
return False
return True
@staticmethod
async def get(key: str, default: Any = None) -> Any:
"""Get a config value by key. Returns default if not set."""
if not Config.persistent_enabled_for(key):
return Config.default_value(key, default)
async with get_async_db() as db:
row = await db.get(Config, key)
return row.value if row else Config.default_value(key, default)
@staticmethod
async def get_many(*keys: str) -> dict:
"""Get multiple config values. Returns {key: value} for keys that exist."""
disabled_values = {
key: Config.default_value(key)
for key in keys
if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS
}
enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)}
if not enabled_keys:
return disabled_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.in_(enabled_keys)))
values = {row.key: row.value for row in result.scalars().all()}
return {
key: values.get(key, Config.default_value(key))
for key in keys
if key in values or key in Config.DEFAULTS or key in disabled_values
}
@staticmethod
async def get_namespace(namespace: str) -> dict:
"""Get all config keys under a dotted namespace."""
default_values = {
key: value
for key, value in Config.DEFAULTS.items()
if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key)
}
if not Config.PERSISTENT_ENABLED:
return default_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%')))
values = {row.key: row.value for row in result.scalars().all()}
values.update(default_values)
return values
@staticmethod
async def get_all() -> dict:
"""Get all config as {key: value}."""
if not Config.PERSISTENT_ENABLED:
return dict(Config.DEFAULTS)
async with get_async_db() as db:
result = await db.execute(select(Config))
values = {row.key: row.value for row in result.scalars().all()}
if not Config.OAUTH_PERSISTENT_ENABLED:
values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')})
return values
@staticmethod
async def upsert(updates: dict) -> None:
"""Upsert multiple config key-value pairs. Raises on failure."""
async with get_async_db() as db:
now = int(time.time())
for key, value in updates.items():
existing = await db.get(Config, key)
if existing:
existing.value = value
existing.updated_at = now
else:
db.add(Config(key=key, value=value, updated_at=now))
await db.commit()
@staticmethod
async def delete(key: str) -> bool:
"""Delete a config key. Returns True if it existed."""
async with get_async_db() as db:
row = await db.get(Config, key)
if row:
await db.delete(row)
await db.commit()
return True
return False
@staticmethod
async def clear() -> None:
"""Delete all config rows."""
from sqlalchemy import delete as sa_delete
async with get_async_db() as db:
await db.execute(sa_delete(Config))
await db.commit()
@staticmethod
async def seed_defaults(defaults: dict) -> None:
"""Insert keys that don't yet exist in the DB.
Called at startup to ensure all known config keys have values.
Existing DB values take precedence over defaults.
"""
async with get_async_db() as db:
result = await db.execute(select(Config.key))
existing_keys = {row[0] for row in result.all()}
now = int(time.time())
new_count = 0
for key, value in defaults.items():
if key not in existing_keys:
db.add(Config(key=key, value=value, updated_at=now))
existing_keys.add(key)
new_count += 1
if new_count:
await db.commit()
log.info('Seeded %d new config defaults', new_count)
+95 -14
View File
@@ -3,13 +3,11 @@ import time
import uuid
from typing import Optional
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -103,7 +101,8 @@ class FeedbackForm(BaseModel):
data: Optional[RatingData] = None
meta: Optional[dict] = None
snapshot: Optional[SnapshotData] = None
model_config = ConfigDict(extra='allow')
# ignore: drop client-supplied id/user_id/version/timestamps at parse time.
model_config = ConfigDict(extra='ignore')
class UserResponse(BaseModel):
@@ -134,6 +133,12 @@ class ModelHistoryEntry(BaseModel):
lost: int
class ModelHistoryCounts(BaseModel):
date: str
won: int = 0
lost: int = 0
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
@@ -145,12 +150,13 @@ class FeedbackTable:
) -> Optional[FeedbackModel]:
async with get_async_db_context(db) as db:
id = str(uuid.uuid4())
# Spread form_data first so server-controlled fields win on duplicate keys.
feedback = FeedbackModel(
**{
**form_data.model_dump(),
'id': id,
'user_id': user_id,
'version': 0,
**form_data.model_dump(),
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
@@ -216,12 +222,19 @@ class FeedbackTable:
) -> FeedbackListResponse:
async with get_async_db_context(db) as db:
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
count_stmt = (
select(func.count(Feedback.id))
.select_from(Feedback)
.join(User, Feedback.user_id == User.id)
)
if filter:
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
model_id_filter = Feedback.data['model_id'].as_string() == model_id
stmt = stmt.filter(model_id_filter)
count_stmt = count_stmt.filter(model_id_filter)
order_by = filter.get('order_by')
direction = filter.get('direction')
@@ -250,9 +263,9 @@ class FeedbackTable:
else:
stmt = stmt.order_by(Feedback.created_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
# Count before pagination without wrapping the ordered item query.
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
if skip:
stmt = stmt.offset(skip)
@@ -317,8 +330,8 @@ class FeedbackTable:
If days=0, returns all time data starting from first feedback.
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
"""
from datetime import datetime, timedelta
from collections import defaultdict
from datetime import datetime, timedelta
async with get_async_db_context(db) as db:
if days == 0:
@@ -375,15 +388,83 @@ class FeedbackTable:
return result
async def get_model_feedback_counts_by_day(
self,
model_id: str,
start_date: Optional[int] = None,
db: Optional[AsyncSession] = None,
) -> list[ModelHistoryCounts]:
"""Get aggregated feedback counts per day for a model, preserving all matching days."""
from collections import defaultdict
from datetime import datetime
async with get_async_db_context(db) as db:
stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
if start_date is not None:
stmt = stmt.filter(Feedback.created_at >= start_date)
result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
rows = result.all()
daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
for created_at, data in rows:
if not data:
continue
rating_str = str(data.get('rating', ''))
if rating_str not in ('1', '-1'):
continue
date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
if rating_str == '1':
daily_counts[date_str]['won'] += 1
else:
daily_counts[date_str]['lost'] += 1
return [
ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
for date_str, counts in sorted(daily_counts.items())
]
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
async def get_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async def get_feedbacks_by_user_id(
self,
user_id: str,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
) -> FeedbackListResponse:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc()))
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
stmt = (
select(Feedback, User)
.join(User, Feedback.user_id == User.id)
.filter(Feedback.user_id == user_id)
.order_by(Feedback.updated_at.desc())
)
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
items = result.all()
feedbacks = []
for feedback, user in items:
feedback_model = FeedbackModel.model_validate(feedback)
user_model = UserResponse.model_validate(user)
feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model))
return FeedbackListResponse(items=feedbacks, total=total)
async def update_feedback_by_id(
self, id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
+127 -73
View File
@@ -1,36 +1,33 @@
"""File upload models, forms, and database operations."""
from __future__ import annotations
import logging
import time
from typing import Optional
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import sanitize_metadata
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
####################
# Files DB Schema
# What is written here bears witness. Let the testimony
# remain as it was given, and let none tamper with it.
####################
class File(Base):
class File(Base): # uploaded file record
__tablename__ = 'file'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
user_id = Column(String, index=True) # owner user id
hash = Column(Text, nullable=True)
filename = Column(Text)
filename = Column(Text) # original upload filename
path = Column(Text, nullable=True)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger)
created_at = Column(BigInteger, index=True) # upload timestamp
updated_at = Column(BigInteger)
@@ -39,27 +36,23 @@ class FileModel(BaseModel):
id: str
user_id: str
hash: Optional[str] = None
hash: str | None = None
filename: str
path: Optional[str] = None
path: str | None = None
data: Optional[dict] = None
meta: Optional[dict] = None
data: dict | None = None
meta: dict | None = None
created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch
####################
# Forms
####################
created_at: int | None # timestamp in epoch
updated_at: int | None # timestamp in epoch
# --- metadata structures ---
class FileMeta(BaseModel):
name: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
name: str | None = None
content_type: str | None = None
size: int | None = None
model_config = ConfigDict(extra='allow')
@@ -84,22 +77,22 @@ class FileMeta(BaseModel):
class FileModelResponse(BaseModel):
id: str
user_id: str
hash: Optional[str] = None
hash: str | None = None
filename: str
data: Optional[dict] = None
meta: Optional[FileMeta] = None
data: dict | None = None
meta: FileMeta | None = None
created_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
updated_at: int | None = None # timestamp in epoch, optional for legacy files
model_config = ConfigDict(extra='allow')
class FileMetadataResponse(BaseModel):
id: str
hash: Optional[str] = None
meta: Optional[dict] = None
hash: str | None = None
meta: dict | None = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -111,7 +104,7 @@ class FileListResponse(BaseModel):
class FileForm(BaseModel):
id: str
hash: Optional[str] = None
hash: str | None = None
filename: str
path: str
data: dict = {}
@@ -119,15 +112,15 @@ class FileForm(BaseModel):
class FileUpdateForm(BaseModel):
hash: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
hash: str | None = None
data: dict | None = None
meta: dict | None = None
class FilesTable:
async def insert_new_file(
self, user_id: str, form_data: FileForm, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
self, user_id: str, form_data: FileForm, db: AsyncSession | None = None
) -> FileModel | None:
async with get_async_db_context(db) as db:
file_data = form_data.model_dump()
@@ -156,22 +149,26 @@ class FilesTable:
return None
except Exception as e:
log.exception(f'Error inserting a new file: {e}')
return None
return None # insertion failed
async def get_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FileModel]:
async def get_file_by_id(
self,
id: str,
db: AsyncSession | None = None,
) -> FileModel | None:
"""Look up a file by its primary key."""
try:
async with get_async_db_context(db) as db:
try:
file = await db.get(File, id)
return FileModel.model_validate(file) if file else None
except Exception:
file = await db.get(File, id)
if not file:
return None
return FileModel.model_validate(file)
except Exception:
return None
async def get_file_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
self, id: str, user_id: str, db: AsyncSession | None = None
) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id, user_id=user_id))
@@ -183,9 +180,7 @@ class FilesTable:
except Exception:
return None
async def get_file_metadata_by_id(
self, id: str, db: Optional[AsyncSession] = None
) -> Optional[FileMetadataResponse]:
async def get_file_metadata_by_id(self, id: str, db: AsyncSession | None = None) -> FileMetadataResponse | None:
async with get_async_db_context(db) as db:
try:
file = await db.get(File, id)
@@ -201,12 +196,24 @@ class FilesTable:
except Exception:
return None
async def get_files(self, db: Optional[AsyncSession] = None) -> list[FileModel]:
async def get_files(self, db: AsyncSession | None = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
async def count_files_by_user_id(
self,
user_id: str | None = None,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as db:
stmt = select(func.count(File.id))
if user_id:
stmt = stmt.filter_by(user_id=user_id)
result = await db.execute(stmt)
return result.scalar() or 0
async def check_access_by_user_id(self, id, user_id, permission='write', db: AsyncSession | None = None) -> bool:
file = await self.get_file_by_id(id, db=db)
if not file:
return False
@@ -215,13 +222,13 @@ class FilesTable:
# Implement additional access control logic here as needed
return False
async def get_files_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[FileModel]:
async def get_files_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc()))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def get_file_metadatas_by_ids(
self, ids: list[str], db: Optional[AsyncSession] = None
self, ids: list[str], db: AsyncSession | None = None
) -> list[FileMetadataResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(
@@ -240,17 +247,17 @@ class FilesTable:
for row in result.all()
]
async def get_files_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]:
async def get_files_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File).filter_by(user_id=user_id))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def get_file_list(
self,
user_id: Optional[str] = None,
user_id: str | None = None,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
db: AsyncSession | None = None,
) -> 'FileListResponse':
async with get_async_db_context(db) as db:
stmt = select(File)
@@ -290,11 +297,11 @@ class FilesTable:
async def search_files(
self,
user_id: Optional[str] = None,
user_id: str | None = None,
filename: str = '*',
skip: int = 0,
limit: int = 100,
db: Optional[AsyncSession] = None,
db: AsyncSession | None = None,
) -> list[FileModel]:
"""
Search files with glob pattern matching, optional user filter, and pagination.
@@ -323,8 +330,8 @@ class FilesTable:
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def update_file_by_id(
self, id: str, form_data: FileUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
self, id: str, form_data: FileUpdateForm, db: AsyncSession | None = None
) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id))
@@ -347,8 +354,8 @@ class FilesTable:
return None
async def update_file_hash_by_id(
self, id: str, hash: Optional[str], db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
self, id: str, hash: str | None, db: AsyncSession | None = None
) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id))
@@ -361,9 +368,7 @@ class FilesTable:
except Exception:
return None
async def update_file_data_by_id(
self, id: str, data: dict, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
async def update_file_data_by_id(self, id: str, data: dict, db: AsyncSession | None = None) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id))
@@ -375,9 +380,7 @@ class FilesTable:
except Exception as e:
return None
async def update_file_metadata_by_id(
self, id: str, meta: dict, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
async def update_file_metadata_by_id(self, id: str, meta: dict, db: AsyncSession | None = None) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id))
@@ -389,7 +392,58 @@ class FilesTable:
except Exception:
return None
async def delete_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async def update_file_name_by_id(self, id: str, name: str, db: AsyncSession | None = None) -> FileModel | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(File).filter_by(id=id))
file = result.scalars().first()
file.filename = name
file.meta = {**(file.meta if file.meta else {}), 'name': name}
file.updated_at = int(time.time())
await db.commit()
return FileModel.model_validate(file)
except Exception:
return None
async def get_pending_files_for_knowledge(
self, knowledge_id: str, db: AsyncSession | None = None
) -> list[FileModelResponse]:
"""Return files still being processed for this knowledge base.
These are files uploaded with ``meta.data.knowledge_id`` set, whose
``data.status`` is still ``pending`` or ``processing``, and which
have not yet been added to the ``knowledge_file`` join table.
The JSON subscript syntax (``Column['key']['subkey'].as_string()``)
is supported by both SQLite (``json_extract``) and PostgreSQL
(``->>``/``->``).
"""
async with get_async_db_context(db) as db:
try:
# Lazy import to avoid circular dependency
from open_webui.models.knowledge import KnowledgeFile
# Subquery: file IDs already linked to this knowledge base
linked_ids = (
select(KnowledgeFile.file_id).filter(KnowledgeFile.knowledge_id == knowledge_id).correlate(None)
)
stmt = (
select(File)
.filter(
File.meta['data']['knowledge_id'].as_string() == knowledge_id,
File.data['status'].as_string().in_(['pending', 'processing']),
File.id.notin_(linked_ids),
)
.order_by(File.created_at.desc())
)
result = await db.execute(stmt)
return [FileModelResponse.model_validate(f, from_attributes=True) for f in result.scalars().all()]
except Exception as e:
log.warning(f'Error fetching pending files for knowledge {knowledge_id}: {e}')
return []
async def delete_file_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
async with get_async_db_context(db) as db:
try:
await db.execute(delete(File).filter_by(id=id))
@@ -399,7 +453,7 @@ class FilesTable:
except Exception:
return False
async def delete_all_files(self, db: Optional[AsyncSession] = None) -> bool:
async def delete_all_files(self, db: AsyncSession | None = None) -> bool:
async with get_async_db_context(db) as db:
try:
await db.execute(delete(File))
@@ -410,4 +464,4 @@ class FilesTable:
return False
Files = FilesTable()
Files = FilesTable() # singleton files repository

Some files were not shown because too many files have changed in this diff Show More