Compare commits

...
200 Commits
Author SHA1 Message Date
Tim Baek 3660bc00fd Merge pull request #24492 from open-webui/dev
0.9.5
2026-05-10 14:13:55 -04: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
Classic298andyantongggg 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
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
Classic298andKadirArslan 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
Classic298andkikayli 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
Classic298andbrodmart 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
Classic298anddestination-one 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
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
Classic298andMatteo Panzeri 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
Classic298andMatteo Panzeri 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
Tim Baek f51d2b026f Merge pull request #24483 from open-webui/dev
0.9.4
2026-05-09 03:50:05 -04: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
+11 adc9076d17 0.9.3 (#24482)
* refac

* refac

* refac

* Merge pull request #24356 from Classic298/patch-1

doc/chore: Update SECURITY.md

* refac

* refac

* refac

* refac

* refac

* chore: Update SECURITY.md (#24363)

* Update SECURITY.md

* Update SECURITY.md

* Implement asynchronous database ping for health checks (#24380)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: prevent STT from blocking the uvicorn event loop (#24338)

The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169

* refac

* fix: open file content in new window when clicking file name in FileItemModal (#24125)

Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379)

* Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370)

* refac

* refac

* refac

* refac

* refac

* fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)

Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206

* refac

* refac

* refac

* refac

* refac

* refac

* Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384)

* refac

* refac

* refac

* refac

* refac

* refac

* refac: apply DOMPurify to excel and office HTML render assignments (#24468)

* I18n/improve chinese translation (#24194)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)

get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* Update catalan translation.json (#24174)

* perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)

Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* i18n: Add Tagalog (Filipino) translation (#24254)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* style(env): satisfy ruff (datetime alias, line length, identity check) (#24118)

* Korean Translation Update (#24087)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: brave search llm context

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)

asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>

* fix:image url validation and signout post (#24420)

* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* chore: format

* chore: changelog (#24358)

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* doc: changelog

* refac

* refac

* refac

* chore: format

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Athanasios Oikonomou <athoik@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Vincent Agra <agravj007@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Shamil <ashm.tech@proton.me>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: looselyhuman <fieldian@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
2026-05-09 03:17:07 -04:00
Timothy Jaeryang Baek 413dcae8a2 refac 2026-05-09 16:11:19 +09:00
Timothy Jaeryang Baek d34d4297ba chore: format 2026-05-09 16:08:22 +09:00
Timothy Jaeryang Baek 6116c6dca0 refac 2026-05-09 16:06:09 +09:00
Timothy Jaeryang Baek 93931efaa7 refac 2026-05-09 16:05:21 +09:00
Timothy Jaeryang Baek 3ccf263b10 refac 2026-05-09 15:46:33 +09:00
Timothy Jaeryang Baek 75e72ea2f9 doc: changelog 2026-05-09 15:41:58 +09:00
Classic298 b94aad2895 chore: changelog (#24358)
* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog
2026-05-09 15:26:04 +09:00
Timothy Jaeryang Baek 7bcc0e2e5c chore: format 2026-05-09 15:25:27 +09:00
Timothy Jaeryang Baek 46ff3abbb8 refac 2026-05-09 15:23:00 +09:00
Timothy Jaeryang Baek aa51ce482c refac 2026-05-09 15:21:31 +09:00
Timothy Jaeryang Baek 251b80ebec refac 2026-05-09 15:14:32 +09:00
Timothy Jaeryang Baek 4d99baa292 refac 2026-05-09 15:04:09 +09:00
Timothy Jaeryang Baek 3fcad2f627 refac 2026-05-09 08:28:29 +09:00
Timothy Jaeryang Baek 04bd0425ea refac 2026-05-09 07:56:58 +09:00
Timothy Jaeryang Baek 485d689cfd refac 2026-05-09 07:52:15 +09:00
Timothy Jaeryang Baek 85c7373f68 refac 2026-05-09 07:37:53 +09:00
Timothy Jaeryang Baek 11e076817a refac 2026-05-09 07:34:46 +09:00
Classic298 cfd2888545 fix:image url validation and signout post (#24420)
* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.
2026-05-09 07:33:31 +09:00
+5 adda20509c fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)
asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 07:15:24 +09:00
Timothy Jaeryang Baek e1dce99147 refac 2026-05-09 07:13:36 +09:00
Timothy Jaeryang Baek a938c8ae2e refac 2026-05-09 07:11:17 +09:00
Timothy Jaeryang Baek 5b80932e59 refac 2026-05-09 06:56:22 +09:00
Timothy Jaeryang Baek 2ba6b423aa refac 2026-05-09 06:50:11 +09:00
Timothy Jaeryang Baek 02f9fe7890 refac 2026-05-09 06:49:41 +09:00
Timothy Jaeryang Baek 29f6c72e87 refac 2026-05-09 06:44:42 +09:00
Timothy Jaeryang Baek bb0e6cb108 refac 2026-05-09 06:41:42 +09:00
Timothy Jaeryang Baek 6700f7bb72 feat: brave search llm context 2026-05-09 06:34:25 +09:00
Timothy Jaeryang Baek 1baf73bdd5 refac 2026-05-09 06:34:03 +09:00
Timothy Jaeryang Baek 1d892ce2c5 refac 2026-05-09 06:33:26 +09:00
Timothy Jaeryang Baek 794b97025d refac 2026-05-09 06:32:34 +09:00
Timothy Jaeryang Baek ee3b82926b refac 2026-05-09 06:25:38 +09:00
Timothy Jaeryang Baek 38a382ef88 refac 2026-05-09 06:23:51 +09:00
Timothy Jaeryang Baek 34146ab60f refac 2026-05-09 06:20:27 +09:00
Timothy Jaeryang Baek f70b0da156 refac 2026-05-09 06:16:27 +09:00
Timothy Jaeryang Baek af5628f8ef refac 2026-05-09 06:13:58 +09:00
Timothy Jaeryang Baek 9907c0a25a refac 2026-05-09 06:01:02 +09:00
+3 d78c247036 Korean Translation Update (#24087)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:31:49 +09:00
Shamil ae0827cec0 style(env): satisfy ruff (datetime alias, line length, identity check) (#24118) 2026-05-09 05:30:09 +09:00
Timothy Jaeryang Baek 064fdecb67 refac 2026-05-09 05:29:15 +09:00
Timothy Jaeryang Baek bf4f44ee9c refac 2026-05-09 05:27:47 +09:00
Timothy Jaeryang Baek 212bb68a66 refac 2026-05-09 05:27:32 +09:00
+3 aff78e4958 i18n: Add Tagalog (Filipino) translation (#24254)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:25:44 +09:00
Timothy Jaeryang Baek ae43562b86 refac 2026-05-09 05:24:50 +09:00
Timothy Jaeryang Baek 13693554f1 refac 2026-05-09 05:20:28 +09:00
Classic298andClaude 1a3e5ef4c1 perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)
Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:20:13 +09:00
Aleix Dorca 26b1a3d7dc Update catalan translation.json (#24174) 2026-05-09 05:17:48 +09:00
Timothy Jaeryang Baek 3ab7b777b1 refac 2026-05-09 05:15:12 +09:00
Timothy Jaeryang Baek 1789303886 refac 2026-05-09 05:14:55 +09:00
Classic298andClaude 41107a34ca perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)
get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:12:51 +09:00
Shirasawa 114c99ae2f I18n/improve chinese translation (#24194)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-05-09 05:11:48 +09:00
Classic298 3746339cfc refac: apply DOMPurify to excel and office HTML render assignments (#24468) 2026-05-09 05:10:31 +09:00
Timothy Jaeryang Baek 7eeff2fdf9 refac 2026-05-09 05:09:20 +09:00
Timothy Jaeryang Baek 55e7c7854b refac 2026-05-09 05:04:51 +09:00
Timothy Jaeryang Baek c978a788c8 refac 2026-05-09 05:03:38 +09:00
Timothy Jaeryang Baek 3d48596c9e refac 2026-05-09 04:56:25 +09:00
Timothy Jaeryang Baek 072d2000f3 refac 2026-05-09 04:53:47 +09:00
Timothy Jaeryang Baek 9386fc83a3 refac 2026-05-09 04:49:18 +09:00
Jacob Leksan b63da90ae4 Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384) 2026-05-09 04:46:00 +09:00
Timothy Jaeryang Baek 23ff9943a9 refac 2026-05-09 04:44:20 +09:00
Timothy Jaeryang Baek 33e588cf09 refac 2026-05-09 04:39:44 +09:00
Timothy Jaeryang Baek 005df577fe refac 2026-05-09 04:36:43 +09:00
Timothy Jaeryang Baek 1b4cd705d0 refac 2026-05-09 04:36:23 +09:00
Timothy Jaeryang Baek 8ffc3d746f refac 2026-05-09 04:22:46 +09:00
Timothy Jaeryang Baek c1202a2327 refac 2026-05-09 04:17:58 +09:00
Classic298 55d1db1f38 fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)
Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206
2026-05-09 04:11:52 +09:00
Timothy Jaeryang Baek 6082e1adae refac 2026-05-09 04:03:49 +09:00
Timothy Jaeryang Baek c6763521c0 refac 2026-05-09 03:46:08 +09:00
Timothy Jaeryang Baek 7c398a625a refac 2026-05-09 03:45:56 +09:00
Timothy Jaeryang Baek cdfcbc4af6 refac 2026-05-09 03:40:23 +09:00
Timothy Jaeryang Baek 4d766a3edf refac 2026-05-09 03:19:48 +09:00
Classic298 d06e6d6ddc Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370) 2026-05-09 03:19:25 +09:00
Jacob Leksan 8b78821ba4 Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379) 2026-05-09 03:17:47 +09:00
Timothy Jaeryang Baek 552bbcecfa refac 2026-05-09 03:15:53 +09:00
Timothy Jaeryang Baek f152ad36b3 refac 2026-05-09 03:06:19 +09:00
Timothy Jaeryang Baek 60ea4214aa refac 2026-05-09 02:58:17 +09:00
Timothy Jaeryang Baek bc4d6eef33 refac 2026-05-09 02:56:15 +09:00
Timothy Jaeryang Baek cde72dab71 refac 2026-05-09 02:54:09 +09:00
Timothy Jaeryang Baek ff791b4814 refac 2026-05-09 02:43:07 +09:00
Timothy Jaeryang Baek 7eaecbad5a refac 2026-05-09 02:38:08 +09:00
Timothy Jaeryang Baek 0103d7e82c refac 2026-05-09 02:31:30 +09:00
Timothy Jaeryang Baek 3309f5d9f1 refac 2026-05-09 02:25:26 +09:00
Athanasios Oikonomou e451f8f63b fix: open file content in new window when clicking file name in FileItemModal (#24125)
Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.
2026-05-09 02:09:35 +09:00
Timothy Jaeryang Baek 6dff85b9d2 refac 2026-05-09 02:08:08 +09:00
Classic298 7e275c1daa fix: prevent STT from blocking the uvicorn event loop (#24338)
The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169
2026-05-09 02:05:28 +09:00
Timothy Jaeryang Baek 1c1c8b18e5 refac 2026-05-09 02:04:36 +09:00
Timothy Jaeryang Baek 55a572cd39 refac 2026-05-09 02:04:26 +09:00
Timothy Jaeryang Baek 9adc0c442a refac 2026-05-09 02:02:02 +09:00
Timothy Jaeryang Baek fd3368c0bf refac 2026-05-09 01:55:51 +09:00
Timothy Jaeryang Baek ef6d4f2d6c refac 2026-05-09 01:50:58 +09:00
Timothy Jaeryang Baek 6bdc2ffa79 refac 2026-05-09 01:31:42 +09:00
Timothy Jaeryang Baek b72019db39 refac 2026-05-09 01:31:04 +09:00
Timothy Jaeryang Baek 2977910ffd refac 2026-05-09 01:25:01 +09:00
Timothy Jaeryang Baek f39f4a86ae refac 2026-05-09 01:22:25 +09:00
Timothy Jaeryang Baek 1dee67b64d refac 2026-05-09 01:21:17 +09:00
Jacob Leksan 2a18dc98ac Implement asynchronous database ping for health checks (#24380) 2026-05-09 01:20:11 +09:00
Classic298 1f977d072e chore: Update SECURITY.md (#24363)
* Update SECURITY.md

* Update SECURITY.md
2026-05-09 01:19:30 +09:00
Timothy Jaeryang Baek 4754ece4a2 refac 2026-05-09 01:17:57 +09:00
Timothy Jaeryang Baek 5c3edc2539 refac 2026-05-09 01:17:33 +09:00
Timothy Jaeryang Baek 4fe2de7864 refac 2026-05-09 01:13:16 +09:00
Timothy Jaeryang Baek a32d26e61d refac 2026-05-05 04:21:23 +09:00
Timothy Jaeryang Baek 989d5fd4e2 refac 2026-05-05 04:05:15 +09:00
Classic298 4e6a7baab7 Merge pull request #24356 from Classic298/patch-1
doc/chore: Update SECURITY.md
2026-05-05 03:45:57 +09:00
Timothy Jaeryang Baek cde21b9f6d refac 2026-05-05 03:33:47 +09:00
Timothy Jaeryang Baek 86df8bf27e refac 2026-05-05 02:41:22 +09:00
Timothy Jaeryang Baek 5bc80b145f refac 2026-05-04 23:57:20 +09:00
+2 8dae237a0b 0.9.2 (#24081)
* refac

* fix: remove reactive label from onDestroy in Markdown

* Update fi-FI translation.json (#24010)

Added missing translations.

* refac

* i18n: update ko-KR translations (conflict solved) (#23949)

* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation

* i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)

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.

* fix(utils): Switch throttle decorator to async (#23979)

After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>

* refac

* refac

* refac

* refac

* feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>

* refac

* refac

* Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011)

* feat(ui): add citation source overflow badge (#23918)

* refac

* i18n: enhance and expand Dutch language translations (#23944)

* refac

* refac

* refac

* perf: redirect default model profile image to canonical static URL (#24015)

- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* changelog (#24072)

* refactor(firecrawl): use v2 API directly (#23934)

Co-authored-by: Tim Baek <tim@openwebui.com>

* chore: bump

* perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)

The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.

* refac

* chore: format

* chore: i18n

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-04-24 05:56:03 -04:00
Timothy Jaeryang Baek 4e2240aada refac 2026-04-24 18:55:39 +09:00
Timothy Jaeryang Baek f93d20ac42 chore: i18n 2026-04-24 18:48:45 +09:00
Timothy Jaeryang Baek 8ff7ff459b chore: format 2026-04-24 18:48:21 +09:00
Timothy Jaeryang Baek f48b8ffbf0 refac 2026-04-24 18:38:57 +09:00
Constantine 3560d2f630 perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)
The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.
2026-04-24 18:34:57 +09:00
Timothy Jaeryang Baek 3aeb691d98 chore: bump 2026-04-24 18:33:27 +09:00
RomualdYTandTim Baek e0d6074cd2 refactor(firecrawl): use v2 API directly (#23934)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-04-24 18:32:08 +09:00
Classic298 b1bd3084f0 changelog (#24072) 2026-04-24 18:30:51 +09:00
Timothy Jaeryang Baek 70b28b629e refac 2026-04-24 18:29:39 +09:00
Timothy Jaeryang Baek 3d1e355df7 refac 2026-04-24 18:20:10 +09:00
Timothy Jaeryang Baek 7102a63c82 refac 2026-04-24 18:06:19 +09:00
Timothy Jaeryang Baek 1cea8ec7d4 refac 2026-04-24 17:59:45 +09:00
Timothy Jaeryang Baek a7a92d2d9b refac 2026-04-24 17:49:22 +09:00
Timothy Jaeryang Baek 2419899ac6 refac 2026-04-24 17:34:12 +09:00
Timothy Jaeryang Baek 60f67c7c17 refac 2026-04-24 17:07:23 +09:00
Timothy Jaeryang Baek 34a55d4524 refac 2026-04-24 17:06:36 +09:00
Timothy Jaeryang Baek 9771898c58 refac 2026-04-24 17:04:47 +09:00
Timothy Jaeryang Baek 752238247c refac 2026-04-24 16:47:30 +09:00
Timothy Jaeryang Baek 3e14524154 refac 2026-04-24 16:39:44 +09:00
Timothy Jaeryang Baek d8b55afb00 refac 2026-04-24 16:37:02 +09:00
Timothy Jaeryang Baek 62693938a3 refac 2026-04-24 16:36:07 +09:00
Jacob Leksan 465d6fe514 feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009) 2026-04-24 16:33:46 +09:00
Timothy Jaeryang Baek d6b73ea2f2 refac 2026-04-24 16:31:02 +09:00
Timothy Jaeryang Baek 5774ab4984 refac 2026-04-24 16:26:34 +09:00
Timothy Jaeryang Baek db05fdaf83 refac 2026-04-24 16:23:28 +09:00
Timothy Jaeryang Baek d740b545a4 refac 2026-04-24 16:21:37 +09:00
Timothy Jaeryang Baek 678c44c7cd refac 2026-04-24 16:17:46 +09:00
Timothy Jaeryang Baek 5cc55e2278 refac 2026-04-24 15:51:54 +09:00
Timothy Jaeryang Baek 26711c1bcc refac 2026-04-24 15:46:08 +09:00
Jacob Leksan f2cb63140c perf: redirect default model profile image to canonical static URL (#24015)
- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor
2026-04-24 15:45:10 +09:00
Timothy Jaeryang Baek a76a779c01 refac 2026-04-24 15:40:02 +09:00
Timothy Jaeryang Baek a766521933 refac 2026-04-24 15:39:12 +09:00
Timothy Jaeryang Baek 7da6b82471 refac 2026-04-24 15:35:59 +09:00
Lucas 6089de0b17 i18n: enhance and expand Dutch language translations (#23944) 2026-04-24 15:30:06 +09:00
Timothy Jaeryang Baek b87c755574 refac 2026-04-24 15:29:36 +09:00
RomualdYT b73538ece7 feat(ui): add citation source overflow badge (#23918) 2026-04-24 15:26:53 +09:00
Jacob Leksan 258e9f917b Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011) 2026-04-24 15:25:54 +09:00
Timothy Jaeryang Baek 6ecba19447 refac 2026-04-24 15:21:52 +09:00
Timothy Jaeryang Baek 90584ab6f3 refac 2026-04-24 15:21:37 +09:00
58bc254809 feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-04-24 15:19:37 +09:00
Timothy Jaeryang Baek 0e311a95a7 refac 2026-04-24 15:16:37 +09:00
Timothy Jaeryang Baek d0e51bde5d refac 2026-04-24 15:03:29 +09:00
Timothy Jaeryang Baek 4dc5c1eb4f refac 2026-04-24 15:00:47 +09:00
Timothy Jaeryang Baek 89669f3fa1 refac 2026-04-24 14:40:17 +09:00
tcx4c70 f6bd08c852 fix(utils): Switch throttle decorator to async (#23979)
After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2026-04-24 14:39:45 +09:00
joaoback 9b577868c8 i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)
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-04-24 14:38:33 +09:00
Teay 91d9870266 i18n: update ko-KR translations (conflict solved) (#23949)
* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation
2026-04-24 14:38:12 +09:00
Timothy Jaeryang Baek d47993385a refac 2026-04-24 14:35:15 +09:00
Kylapaallikko a4eb10269e Update fi-FI translation.json (#24010)
Added missing translations.
2026-04-24 14:33:06 +09:00
Tim Baek ef77c3d45b Merge pull request #24048 from Algorithm5838/fix/markdown-ondestroy-reactive
fix: remove reactive label from onDestroy in Markdown
2026-04-24 01:32:43 -04:00
Algorithm5838 83f3a9c543 fix: remove reactive label from onDestroy in Markdown 2026-04-23 22:26:11 +03:00
Timothy Jaeryang Baek d56d74b387 refac 2026-04-23 19:39:06 +09:00
224 changed files with 13564 additions and 5223 deletions
+1
View File
@@ -18,6 +18,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
**Before submitting, make sure you've checked the following:**
- [ ] **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:** 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.
+185
View File
@@ -5,6 +5,191 @@ 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.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
- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832)
- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159)
- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329)
- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133)
- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840)
- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc)
- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384)
- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103)
- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416)
- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462)
- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4)
- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216)
- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836)
- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166)
- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120)
- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425)
- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27)
- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244)
- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013)
- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412)
- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries.
- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded.
### Fixed
- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089)
- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162)
- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092)
- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176)
- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105)
- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264)
- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92)
- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135)
- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424)
- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2)
- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237)
- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376)
- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254)
- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810)
- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370)
- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999)
- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297)
- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391)
- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243)
- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c)
- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c)
- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263)
- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142)
- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090)
- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143)
- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152)
- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138)
- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149)
- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137)
- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169)
- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198)
- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125)
- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332)
- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa)
- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468)
- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433)
- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319)
- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346)
- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377)
- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188)
- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166)
- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a)
- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241)
- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096)
- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270)
- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897)
- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411)
- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `<think>` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844)
- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use.
- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components.
- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early.
### 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.
- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420)
## [0.9.2] - 2026-04-24
### Added
- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [#23945](https://github.com/open-webui/open-webui/pull/23945)
- 🔥 **Firecrawl v2 API.** Firecrawl web loading now uses the v2 API directly with proper retry logic, exponential backoff on rate limits, and configurable timeout handling, improving reliability for both cloud and self-hosted Firecrawl setups. [#23934](https://github.com/open-webui/open-webui/pull/23934)
- ⏰ **Calendar event reminder customization.** Calendar events now support a configurable `reminder_minutes` parameter, allowing models to set custom reminder durations instead of the default 10-minute notification.
- 🔑 **Custom API key header.** Administrators can now configure a custom header name for API key authentication via the `CUSTOM_API_KEY_HEADER` environment variable, enabling compatibility with reverse proxies that use the `Authorization` header for their own authentication.
- 🔌 **OAuth session disconnection.** Users can now disconnect OAuth sessions for specific providers (e.g., MCP connections) through a new API endpoint, enabling cleaner re-authentication workflows.
- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918)
- ⚡ **Model list performance.** Model list API responses now strip base64 profile image data from paginated results, and model tags are fetched via a dedicated efficient query instead of loading all models. This significantly reduces payload sizes and improves workspace Models page responsiveness.
- ⚡ **Model avatar cache reuse.** Default model profile images now redirect to a shared static path instead of reading files from disk per-request, reducing repeated I/O and improving loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015)
- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load with preload links, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011)
- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048)
- 📊 **Telemetry gauge reliability.** OpenTelemetry user gauge callbacks now use synchronous database queries directly, eliminating cross-thread async bridging issues that could cause silent failures in metric collection.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded.
### Fixed
- 🔧 **MCP task cancellation stability.** Interrupted MCP tool calls no longer cause CPU spikes or runaway cleanup behavior. MCP client disconnection now runs in the same asyncio task as connection, respecting cancel scope constraints, and chat-active events are properly shielded during cancellation.
- 🧠 **Persistent chat skill injection.** Skills mentioned in persisted chats now inject into the system prompt reliably. Skill ID extraction from `<$skillId|label>` message tags is now handled server-side, and tags are stripped before messages reach the model.
- 🗄️ **Async database driver migration.** The async database backend now uses psycopg (v3) instead of asyncpg, eliminating brittle SSL parameter translation and supporting native libpq connection strings including `sslmode`, `options`, and `target_session_attrs` without any stripping or conversion.
- 🐳 **Docker ARM64 reliability.** Docker images built for arm64 via QEMU cross-compilation no longer produce 0-byte corrupted Python dependencies. `UV_LINK_MODE=copy` is now set in the Dockerfile to force reliable file installation.
- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979)
- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009)
- 🔇 **Fetch URL null content guard.** The `fetch_url` built-in tool now safely handles `None` content returned by web loaders instead of crashing with a `TypeError`.
- 🌐 **OAuth discovery fallback.** OAuth protected resource discovery now falls back to well-known RFC 9728 URIs when the `WWW-Authenticate` header doesn't contain a `resource_metadata` link, improving compatibility with more MCP server implementations.
- 🔐 **Session token resolution.** Session user endpoints now gracefully handle missing `Authorization` headers by falling back to cookie and request state tokens, preventing errors when used behind forward-auth proxies.
- 🚫 **Direct API error responses.** Chat completion requests without a WebSocket channel (direct API calls) now return proper HTTP error responses instead of silently returning null on failure.
- 📡 **Cancelled response stream cleanup.** Cancelled chat generation now explicitly closes the upstream response body iterator, preventing orphaned async generators from spinning in anyio internals.
- 🔒 **Model profile image path safety.** Model profile image endpoints now validate and sanitize static asset redirect paths, preventing path traversal through encoded dots or malicious URL patterns.
- 📊 **RAG template validation UI.** The Documents settings page now displays a warning when RAG templates contain multiple `[context]` or `{{CONTEXT}}` placeholders, helping administrators avoid accidental redundant context injection.
- 🧩 **Automation model detection.** The `create_automation` tool now correctly detects the current model ID even when `model_id` is not yet set in metadata, falling back to the model dict.
- 🔄 **MCP resource content handling.** MCP tool results with the `resource` content type are now correctly detected and their `resource.text` payload is extracted, instead of being silently ignored.
- 🔄 **Ollama and OpenAI metadata forwarding.** Ollama and OpenAI proxy routes now forward request metadata to downstream handlers, ensuring consistent context propagation.
- 🧹 **Browser-native message virtualization.** The custom JavaScript-based message culling system (spacers, height caching, scroll listeners) was replaced with CSS `content-visibility: auto`, letting the browser natively skip rendering of off-screen messages without destroying component trees. This eliminates scroll jump artifacts and mount/destroy thrashing while preserving memory efficiency in long conversations.
- 📻 **Redis notification compatibility.** Redis pub/sub now handles missing or incompatible `client_name` support more gracefully, preventing connection errors with certain Redis configurations.
### Changed
- ⚙️ **psycopg v3 async driver.** The async database driver has been migrated from `asyncpg` to `psycopg` (v3). This is a transparent change for most deployments, but custom connection strings with `asyncpg`-specific parameters may need adjustment.
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
## [0.9.1] - 2026-04-21
### Fixed
+4 -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
@@ -135,6 +135,9 @@ RUN apt-get update && \
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
ENV UV_LINK_MODE=copy
RUN set -e; \
pip3 install --no-cache-dir uv; \
if [ "$USE_CUDA" = "true" ]; then \
+1 -1
View File
@@ -47,7 +47,7 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
- 💾 **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.
- 📚 **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, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- 📚 **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.
- 🔍 **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.
+7
View File
@@ -1,6 +1,7 @@
import base64
import os
import random
import sys
from pathlib import Path
from typing import Annotated
@@ -68,12 +69,18 @@ def serve(
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS # Import the workers setting
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
loop = 'none' if sys.platform == 'win32' else 'auto'
uvicorn.run(
'open_webui.main:app',
host=host,
port=port,
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
loop=loop,
)
+85 -5
View File
@@ -328,6 +328,17 @@ class AppConfig:
except Exception as e:
log.error(f'Failed to async-persist config key {key}: {e}')
def _sync_to_redis(self):
"""Push all in-memory config values to Redis, e.g. after a bulk import."""
if not self._redis or not ENABLE_PERSISTENT_CONFIG:
return
for key, pc in self._state.items():
redis_key = f'{self._redis_key_prefix}:config:{key}'
try:
self._redis.set(redis_key, json.dumps(pc.value))
except Exception as e:
log.error(f'Failed to sync config key {key} to Redis: {e}')
def __getattr__(self, key):
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
@@ -1197,6 +1208,12 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig(
tool_server_connections,
)
OAUTH_CLIENT_TIMEOUT = PersistentConfig(
'OAUTH_CLIENT_TIMEOUT',
'oauth.client.timeout',
os.environ.get('OAUTH_CLIENT_TIMEOUT', ''),
)
####################################
# TERMINAL_SERVER
####################################
@@ -1209,6 +1226,11 @@ TERMINAL_SERVER_CONNECTIONS = PersistentConfig(
terminal_server_connections,
)
try:
TERMINAL_PROXY_HEADERS = json.loads(os.environ.get('TERMINAL_PROXY_HEADERS', '{}'))
except Exception:
TERMINAL_PROXY_HEADERS = {}
####################################
# WEBUI
####################################
@@ -1299,10 +1321,16 @@ MODEL_ORDER_LIST = PersistentConfig(
[],
)
try:
default_model_metadata = json.loads(os.environ.get('DEFAULT_MODEL_METADATA', '{}'))
except Exception as e:
log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}')
default_model_metadata = {}
DEFAULT_MODEL_METADATA = PersistentConfig(
'DEFAULT_MODEL_METADATA',
'models.default_metadata',
{},
default_model_metadata,
)
try:
@@ -1348,6 +1376,7 @@ RESPONSE_WATERMARK = PersistentConfig(
os.environ.get('RESPONSE_WATERMARK', ''),
)
IFRAME_CSP = os.environ.get('IFRAME_CSP', '')
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true'
@@ -1442,6 +1471,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
)
@@ -1477,6 +1510,10 @@ USER_PERMISSIONS_CHAT_EDIT = os.environ.get('USER_PERMISSIONS_CHAT_EDIT', 'True'
USER_PERMISSIONS_CHAT_SHARE = os.environ.get('USER_PERMISSIONS_CHAT_SHARE', 'True').lower() == 'true'
USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
os.environ.get('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
)
USER_PERMISSIONS_CHAT_EXPORT = os.environ.get('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
USER_PERMISSIONS_CHAT_STT = os.environ.get('USER_PERMISSIONS_CHAT_STT', 'True').lower() == 'true'
@@ -1557,6 +1594,8 @@ DEFAULT_USER_PERMISSIONS = {
'public_skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING,
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
},
'access_grants': {
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
@@ -2051,6 +2090,12 @@ VOICE_MODE_PROMPT_TEMPLATE = PersistentConfig(
os.environ.get('VOICE_MODE_PROMPT_TEMPLATE', ''),
)
ENABLE_VOICE_MODE_PROMPT = PersistentConfig(
'ENABLE_VOICE_MODE_PROMPT',
'task.voice.prompt.enable',
os.environ.get('ENABLE_VOICE_MODE_PROMPT', 'True').lower() == 'true',
)
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant.
Everything you say will be spoken aloud.
@@ -2627,13 +2672,17 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
)
ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true'
ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true'
ONEDRIVE_CLIENT_ID = os.environ.get('ONEDRIVE_CLIENT_ID', '')
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get('ONEDRIVE_CLIENT_ID_PERSONAL', ONEDRIVE_CLIENT_ID)
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get('ONEDRIVE_CLIENT_ID_BUSINESS', ONEDRIVE_CLIENT_ID)
ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' and bool(
ONEDRIVE_CLIENT_ID_PERSONAL
)
ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' and bool(
ONEDRIVE_CLIENT_ID_BUSINESS
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
'ONEDRIVE_SHAREPOINT_URL',
'onedrive.sharepoint_url',
@@ -2827,6 +2876,18 @@ MISTRAL_OCR_API_KEY = PersistentConfig(
os.getenv('MISTRAL_OCR_API_KEY', ''),
)
PADDLEOCR_VL_BASE_URL = PersistentConfig(
'PADDLEOCR_VL_BASE_URL',
'rag.paddleocr_vl_base_url',
os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080'),
)
PADDLEOCR_VL_TOKEN = PersistentConfig(
'PADDLEOCR_VL_TOKEN',
'rag.paddleocr_vl_token',
os.getenv('PADDLEOCR_VL_TOKEN', ''),
)
BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
'BYPASS_EMBEDDING_AND_RETRIEVAL',
'rag.bypass_embedding_and_retrieval',
@@ -3240,7 +3301,7 @@ ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
WEB_SEARCH_TRUST_ENV = PersistentConfig(
'WEB_SEARCH_TRUST_ENV',
'rag.web.search.trust_env',
os.getenv('WEB_SEARCH_TRUST_ENV', 'False').lower() == 'true',
os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true',
)
@@ -3298,6 +3359,12 @@ BRAVE_SEARCH_API_KEY = PersistentConfig(
os.getenv('BRAVE_SEARCH_API_KEY', ''),
)
BRAVE_SEARCH_CONTEXT_TOKENS = PersistentConfig(
'BRAVE_SEARCH_CONTEXT_TOKENS',
'rag.web.search.brave_search_context_tokens',
int(os.getenv('BRAVE_SEARCH_CONTEXT_TOKENS', '8192')),
)
KAGI_SEARCH_API_KEY = PersistentConfig(
'KAGI_SEARCH_API_KEY',
'rag.web.search.kagi_search_api_key',
@@ -3935,6 +4002,19 @@ AUDIO_STT_SUPPORTED_CONTENT_TYPES = PersistentConfig(
],
)
AUDIO_STT_ALLOWED_EXTENSIONS = PersistentConfig(
'AUDIO_STT_ALLOWED_EXTENSIONS',
'audio.stt.allowed_extensions',
[
ext.strip()
for ext in os.environ.get(
'AUDIO_STT_ALLOWED_EXTENSIONS',
'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg',
).split(',')
if ext.strip()
],
)
AUDIO_STT_AZURE_API_KEY = PersistentConfig(
'AUDIO_STT_AZURE_API_KEY',
'audio.stt.azure.api_key',
+45 -11
View File
@@ -1,21 +1,21 @@
import datetime as dt
import importlib.metadata
import json
import logging
import os
import pkgutil
import sys
import re
import shutil
import sys
import traceback
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
import re
import markdown
from bs4 import BeautifulSoup
from cryptography.hazmat.primitives import serialization
from open_webui.constants import ERROR_MESSAGES
####################################
@@ -43,7 +43,8 @@ except ImportError:
DOCKER = os.environ.get('DOCKER', 'False').lower() == 'true'
# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance
# device type for embedding models - "cpu" (default), "cuda" (nvidia gpu required), or "mps" (apple silicon)
# choosing this correctly can lead to better performance
USE_CUDA = os.environ.get('USE_CUDA_DOCKER', 'false')
if USE_CUDA.lower() == 'true':
@@ -87,7 +88,7 @@ class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
'ts': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec='milliseconds'),
'ts': dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(timespec='milliseconds'),
'level': _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
'msg': record.getMessage(),
'caller': record.name,
@@ -180,7 +181,7 @@ def parse_section(section):
try:
changelog_path = BASE_DIR / 'CHANGELOG.md'
with open(str(changelog_path.absolute()), 'r', encoding='utf8') as file:
with open(str(changelog_path.absolute()), encoding='utf8') as file:
changelog_content = file.read()
except Exception:
@@ -248,6 +249,26 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = os.environ.get('ENABLE_STAR_SESSIONS_MIDDLEWAR
ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true'
####################################
# ENABLE_PROFILE_IMAGE_URL_FORWARDING
####################################
# When True (default), the user and model profile-image endpoints
# honour external http(s) URLs stored in profile_image_url by issuing a
# 302 redirect to the original origin. Set to False to suppress the
# redirect (prevents client-side IP/UA/Referer leaks to attacker-
# controlled origins) and fall through to the default image instead.
ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get('ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True').lower() == 'true'
PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset(
t.strip()
for t in os.environ.get(
'PROFILE_IMAGE_ALLOWED_MIME_TYPES',
'image/png,image/jpeg,image/gif,image/webp',
).split(',')
if t.strip()
)
####################################
# WEBUI_BUILD_HASH
####################################
@@ -339,7 +360,7 @@ DATABASE_SCHEMA = os.environ.get('DATABASE_SCHEMA', None)
DATABASE_POOL_SIZE = os.environ.get('DATABASE_POOL_SIZE', None)
if DATABASE_POOL_SIZE != None:
if DATABASE_POOL_SIZE is not None:
try:
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
except Exception:
@@ -525,6 +546,12 @@ WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_NAME_HEADER'
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_GROUPS_HEADER', None)
WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_ROLE_HEADER', None)
# Custom header name for API key authentication. Defaults to 'x-api-key'.
# Useful when Open WebUI sits behind a reverse proxy / API gateway that
# already uses the Authorization header for its own authentication — set
# this to a unique header (e.g. 'X-OpenWebUI-Key') so the middleware
# checks the custom header instead and avoids the 401 short-circuit.
CUSTOM_API_KEY_HEADER = os.environ.get('CUSTOM_API_KEY_HEADER', 'x-api-key')
ENABLE_PASSWORD_VALIDATION = os.environ.get('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true'
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
@@ -646,7 +673,7 @@ if LICENSE_PUBLIC_KEY:
-----BEGIN PUBLIC KEY-----
{LICENSE_PUBLIC_KEY}
-----END PUBLIC KEY-----
""".encode('utf-8')
""".encode()
)
@@ -806,6 +833,13 @@ else:
AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true'
# When False (default), outbound HTTP requests do not follow 3xx redirects.
# This prevents redirect-based SSRF where a public URL 302-redirects to an
# internal address (RFC 1918, loopback, cloud-metadata 169.254.169.254).
# Set to True only if your deployment requires redirect following and you
# have other SSRF protections in place (e.g. egress firewall).
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.environ.get('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST',
os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'),
+1 -1
View File
@@ -284,7 +284,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if params:
system = params.pop('system', None)
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = await get_function_module_by_id(request, pipe_id)
+99 -98
View File
@@ -1,7 +1,7 @@
import os
import sys
import json
import logging
import ssl as _stdlib_ssl
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
@@ -37,90 +37,84 @@ from typing_extensions import Self
log = logging.getLogger(__name__)
def extract_ssl_mode_from_url(url: str) -> tuple[str, str | None]:
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
#
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
# below strip all SSL-related query params, normalise them, and
# reattach them in the canonical libpq form.
#
# The **async** engine now uses psycopg (v3), which speaks libpq
# natively, so it needs no translation at all — the DATABASE_URL is
# passed through as-is.
# ─────────────────────────────────────────────────────────────────────
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
"""Pop a single-valued query param, returning ``None`` if absent."""
values = params.pop(key, None)
return values[0] if values else None
def _is_postgres_url(url: str) -> bool:
"""Return True if *url* looks like a PostgreSQL connection string."""
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
"""Strip SSL query-string parameters from a PostgreSQL URL.
asyncpg and psycopg2 use different query-string keys for SSL
(``ssl`` vs ``sslmode``). This helper removes **both** from the
URL so that each driver can receive the correct parameter through
its own mechanism (query-string re-injection for psycopg2,
``connect_args`` for asyncpg).
Returns
-------
(url_without_ssl, ssl_mode)
*url_without_ssl* is the original URL with ``ssl`` / ``sslmode``
query parameters removed. *ssl_mode* is the extracted mode
string (e.g. ``'require'``), or ``None`` if neither parameter
was present.
Non-PostgreSQL URLs are returned unchanged with ``ssl_mode=None``.
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
their values. Non-PostgreSQL URLs are returned unchanged with an
empty dict.
"""
if not url or not any(url.startswith(prefix) for prefix in ('postgresql://', 'postgresql+', 'postgres://')):
return url, None
if not _is_postgres_url(url):
return url, {}
parsed = urlparse(url)
query_params = parse_qs(parsed.query, keep_blank_values=True)
qp = parse_qs(parsed.query, keep_blank_values=True)
# Prefer sslmode (libpq canonical) over the asyncpg-only ssl key.
ssl_mode: str | None = None
for key in ('sslmode', 'ssl'):
values = query_params.pop(key, None)
if values and ssl_mode is None:
ssl_mode = values[0]
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
sslmode_val = _pop_first(qp, 'sslmode')
ssl_val = _pop_first(qp, 'ssl')
ssl_mode = sslmode_val or ssl_val
if ssl_mode is None:
# Nothing to strip — return the URL untouched.
return url, None
ssl_dict: dict[str, str] = {}
if ssl_mode:
ssl_dict['sslmode'] = ssl_mode
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
val = _pop_first(qp, key)
if val:
ssl_dict[key] = val
# Rebuild the query string without the SSL keys.
remaining_query = urlencode(query_params, doseq=True)
url_without_ssl = urlunparse(parsed._replace(query=remaining_query))
return url_without_ssl, ssl_mode
if not ssl_dict:
return url, ssl_dict
cleaned_query = urlencode(qp, doseq=True)
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
def build_asyncpg_ssl_args(ssl_mode: str | None) -> dict:
"""Convert a libpq-style SSL mode value to asyncpg ``connect_args``.
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
Returns a dict suitable for unpacking into
``create_async_engine(..., connect_args=...)``.
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
certificate-file keys in the connection string.
"""
if ssl_mode is None:
return {}
mode = ssl_mode.lower()
if mode == 'disable':
return {'connect_args': {'ssl': False}}
if mode in ('allow', 'prefer'):
# asyncpg has no direct equivalent — omit to let it try without.
return {}
if mode == 'require':
# SSL required but no certificate verification (matches libpq).
ctx = _stdlib_ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _stdlib_ssl.CERT_NONE
return {'connect_args': {'ssl': ctx}}
if mode in ('verify-ca', 'verify-full'):
# Full verification — use the system trust store.
ctx = _stdlib_ssl.create_default_context()
if mode == 'verify-ca':
ctx.check_hostname = False
return {'connect_args': {'ssl': ctx}}
# Unknown value — pass through as-is and let asyncpg decide.
return {'connect_args': {'ssl': ssl_mode}}
def reattach_ssl_mode_to_url(url_without_ssl: str, ssl_mode: str | None) -> str:
"""Re-append ``sslmode=<value>`` to a cleaned PostgreSQL URL.
Used for psycopg2 / libpq consumers that expect the canonical
``sslmode`` query-string key.
"""
if ssl_mode is None:
if not ssl_dict:
return url_without_ssl
separator = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{separator}sslmode={ssl_mode}'
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
if not parts:
return url_without_ssl
sep = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{sep}{"&".join(parts)}'
# Backwards-compatible aliases for external callers.
extract_ssl_mode_from_url = extract_ssl_params_from_url
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
class JSONField(types.TypeDecorator):
@@ -150,9 +144,10 @@ class JSONField(types.TypeDecorator):
def handle_peewee_migration(DATABASE_URL):
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DATABASE_URL)
normalized_url = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode)
# 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://'))
@@ -179,32 +174,36 @@ if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
# Normalize SSL params from the URL once; each engine branch re-injects
# the driver-appropriate form.
DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE = extract_ssl_mode_from_url(DATABASE_URL)
# Normalize SSL params from the URL once; the sync engine needs them
# reattached in canonical libpq form for psycopg2.
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
# For psycopg2 (sync engine), re-append sslmode=<value>.
SQLALCHEMY_DATABASE_URL = (
reattach_ssl_mode_to_url(DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE) if DATABASE_SSL_MODE else DATABASE_URL
)
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
def _make_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent."""
"""Convert a sync database URL to its async driver equivalent.
The async engine uses psycopg (v3) which speaks libpq natively,
so all standard connection-string parameters (``sslmode``,
``options``, ``target_session_attrs``, etc.) are passed through
without any translation.
"""
if url.startswith('sqlite+sqlcipher://'):
# SQLCipher has no async driver — not supported for async
raise ValueError(
'sqlite+sqlcipher:// URLs are not supported with async engine. '
'Use standard sqlite:// or postgresql:// instead.'
)
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
# psycopg v3 — auto-selects async mode with create_async_engine
if url.startswith('postgresql+psycopg2://'):
return url.replace('postgresql+psycopg2://', 'postgresql+asyncpg://', 1)
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
if url.startswith('postgresql://'):
return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
if url.startswith('postgres://'):
return url.replace('postgres://', 'postgresql+asyncpg://', 1)
return url.replace('postgres://', 'postgresql+psycopg://', 1)
# For other dialects, return as-is and let SQLAlchemy handle it
return url
@@ -329,10 +328,19 @@ get_db = contextmanager(get_session)
# ASYNC ENGINE (used for ALL runtime database operations)
# ============================================================
# Use the SSL-stripped URL for asyncpg — SSL is injected via connect_args.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(
DATABASE_URL_WITHOUT_SSL if DATABASE_SSL_MODE else SQLALCHEMY_DATABASE_URL
)
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
# all work without any stripping or translation.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
# psycopg v3 cannot run in async mode under Windows' default
# ProactorEventLoop — switch to SelectorEventLoop before creating
# the async engine. This runs at import time, which is early enough
# to cover every entry point (workers, reload, direct invocations).
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
@@ -350,10 +358,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
else:
# Inject asyncpg-compatible SSL connect_args when the user specified
# sslmode/ssl in DATABASE_URL.
asyncpg_ssl_args = build_asyncpg_ssl_args(DATABASE_SSL_MODE)
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = create_async_engine(
@@ -363,20 +367,17 @@ else:
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
**asyncpg_ssl_args,
)
+224 -59
View File
@@ -199,6 +199,7 @@ from open_webui.config import (
AUDIO_STT_ENGINE,
AUDIO_STT_MODEL,
AUDIO_STT_SUPPORTED_CONTENT_TYPES,
AUDIO_STT_ALLOWED_EXTENSIONS,
AUDIO_STT_OPENAI_API_BASE_URL,
AUDIO_STT_OPENAI_API_KEY,
AUDIO_STT_AZURE_API_KEY,
@@ -303,6 +304,8 @@ from open_webui.config import (
DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY,
PADDLEOCR_VL_BASE_URL,
PADDLEOCR_VL_TOKEN,
RAG_TEXT_SPLITTER,
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
@@ -342,6 +345,7 @@ from open_webui.config import (
BING_SEARCH_V7_ENDPOINT,
BING_SEARCH_V7_SUBSCRIPTION_KEY,
BRAVE_SEARCH_API_KEY,
BRAVE_SEARCH_CONTEXT_TOKENS,
EXA_API_KEY,
PERPLEXITY_API_KEY,
PERPLEXITY_MODEL,
@@ -456,6 +460,7 @@ from open_webui.config import (
OAUTH_PROVIDERS,
WEBUI_URL,
RESPONSE_WATERMARK,
IFRAME_CSP,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_ANALYTICS,
@@ -476,6 +481,7 @@ from open_webui.config import (
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
VOICE_MODE_PROMPT_TEMPLATE,
ENABLE_VOICE_MODE_PROMPT,
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
@@ -578,6 +584,8 @@ from open_webui.utils.redis import get_redis_connection
from open_webui.tasks import (
redis_task_command_listener,
list_task_ids_by_item_id,
has_active_tasks,
cleanup_task,
create_task,
stop_task,
stop_item_tasks,
@@ -704,30 +712,34 @@ async def lifespan(app: FastAPI):
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
mock_request = Request(
{
'type': 'http',
'asgi.version': '3.0',
'asgi.spec_version': '2.0',
'method': 'GET',
'path': '/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 12345),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': app,
}
)
log.info('Initializing tool servers...')
try:
mock_request = Request(
{
'type': 'http',
'asgi.version': '3.0',
'asgi.spec_version': '2.0',
'method': 'GET',
'path': '/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 12345),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': app,
}
)
await set_tool_servers(mock_request)
log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)')
except Exception as e:
log.warning(f'Failed to initialize tool servers at startup: {e}')
try:
await set_terminal_servers(mock_request)
log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)')
except Exception as e:
log.warning(f'Failed to initialize tool/terminal servers at startup: {e}')
log.warning(f'Failed to initialize terminal servers at startup: {e}')
# Mark application as ready to accept traffic from a startup perspective.
app.state.startup_complete = True
@@ -1023,6 +1035,8 @@ app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL
app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
app.state.config.PADDLEOCR_VL_BASE_URL = PADDLEOCR_VL_BASE_URL
app.state.config.PADDLEOCR_VL_TOKEN = PADDLEOCR_VL_TOKEN
app.state.config.MINERU_API_MODE = MINERU_API_MODE
app.state.config.MINERU_API_URL = MINERU_API_URL
app.state.config.MINERU_API_KEY = MINERU_API_KEY
@@ -1098,6 +1112,7 @@ app.state.config.YACY_PASSWORD = YACY_PASSWORD
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY
app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = BRAVE_SEARCH_CONTEXT_TOKENS
app.state.config.KAGI_SEARCH_API_KEY = KAGI_SEARCH_API_KEY
app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY
app.state.config.BOCHA_SEARCH_API_KEY = BOCHA_SEARCH_API_KEY
@@ -1285,6 +1300,7 @@ app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFL
app.state.config.STT_ENGINE = AUDIO_STT_ENGINE
app.state.config.STT_MODEL = AUDIO_STT_MODEL
app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES
app.state.config.STT_ALLOWED_EXTENSIONS = AUDIO_STT_ALLOWED_EXTENSIONS
app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
@@ -1357,6 +1373,7 @@ app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = QUERY_GENERATION_PROMPT_TEMP
app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH
app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE
app.state.config.ENABLE_VOICE_MODE_PROMPT = ENABLE_VOICE_MODE_PROMPT
########################################
@@ -1520,6 +1537,108 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
return {'data': models}
class ModelUnloadForm(BaseModel):
model: str
@app.post('/api/models/unload')
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
"""
Unified model unload endpoint.
Resolves the provider that owns the model and calls its native unload mechanism.
Supports: Ollama (keep_alive=0) and llama.cpp (/models/unload).
"""
model_id = form_data.model
# --- Ollama provider ---
ollama_models = getattr(request.app.state, 'OLLAMA_MODELS', None) or {}
if model_id in ollama_models:
url_indices = ollama_models[model_id].get('urls', [])
errors = []
for idx in url_indices:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
key = api_config.get('key', None)
prefix_id = api_config.get('prefix_id', None)
actual_model = model_id
if prefix_id and actual_model.startswith(f'{prefix_id}.'):
actual_model = actual_model[len(f'{prefix_id}.') :]
payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''})
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
'Content-Type': 'application/json',
**({'Authorization': f'Bearer {key}'} if key else {}),
}
async with session.post(
f'{url}/api/generate',
data=payload,
headers=headers,
) as r:
if not r.ok:
errors.append({'url_idx': idx, 'error': await r.text()})
except Exception as e:
log.exception(f'Failed to unload model on Ollama node {idx}: {e}')
errors.append({'url_idx': idx, 'error': str(e)})
if errors:
raise HTTPException(
status_code=500,
detail=f'Failed to unload model on {len(errors)} node(s): {errors}',
)
return {'status': True}
# --- OpenAI-compatible providers ---
openai_models = getattr(request.app.state, 'OPENAI_MODELS', None) or {}
if model_id in openai_models:
model_info = openai_models[model_id]
idx = model_info.get('urlIdx')
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(str(idx), {})
provider = api_config.get('provider', '')
base_url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = (
request.app.state.config.OPENAI_API_KEYS[idx] if idx < len(request.app.state.config.OPENAI_API_KEYS) else ''
)
if provider == 'llama.cpp':
root_url = base_url.rstrip('/').removesuffix('/v1')
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
'Content-Type': 'application/json',
**({'Authorization': f'Bearer {key}'} if key else {}),
}
async with session.post(
f'{root_url}/models/unload',
json={'model': model_id},
headers=headers,
) as r:
if not r.ok:
detail = await r.text()
raise HTTPException(status_code=r.status, detail=detail)
return await r.json()
except HTTPException:
raise
except Exception as e:
log.exception(f'Failed to unload model via llama.cpp: {e}')
raise HTTPException(status_code=500, detail=str(e))
else:
raise HTTPException(
status_code=400,
detail=f'Provider "{provider or "default"}" does not support model unloading',
)
raise HTTPException(status_code=404, detail=f'Model "{model_id}" not found')
##################################
# Embeddings
##################################
@@ -1647,6 +1766,7 @@ async def chat_completion(
'chat_id': form_data.pop('chat_id', None),
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
'assistant_message_id': form_data.pop('assistant_message_id', None),
'session_id': form_data.pop('session_id', None),
'folder_id': form_data.pop('folder_id', None),
'filter_ids': form_data.pop('filter_ids', []),
@@ -1676,7 +1796,9 @@ async def chat_completion(
if metadata.get('chat_id') and user:
chat_id = metadata['chat_id']
if not chat_id.startswith('local:'): # temporary chats are not stored
if not chat_id.startswith('local:') and not chat_id.startswith(
'channel:'
): # temporary/channel chats are not stored
if is_new_chat:
# Build the full history upfront with ALL assistant placeholders
user_message = metadata.get('user_message') or {}
@@ -1719,6 +1841,7 @@ async def chat_completion(
]
if user_message_id
else [],
'files': metadata.get('files') or [],
'tags': [],
'timestamp': int(time.time() * 1000),
},
@@ -1751,6 +1874,16 @@ async def chat_completion(
detail=ERROR_MESSAGES.DEFAULT(),
)
# Persist chat-level files (knowledge collections, docs, etc.)
# The old frontend saveChatHandler did this on every message;
# now the backend owns persistence.
chat_files = metadata.get('files')
if chat_files is not None:
existing_chat = await Chats.get_chat_by_id(chat_id)
if existing_chat:
updated = {**existing_chat.chat, 'files': chat_files}
await Chats.update_chat_by_id(chat_id, updated)
# Save user message to DB
user_message = metadata.get('user_message') or {}
if user_message and user_message.get('id'):
@@ -1832,7 +1965,7 @@ async def chat_completion(
except HTTPException:
raise
except Exception as e:
log.debug(f'Error processing chat metadata: {e}')
log.warning(f'Error processing chat metadata: {e}')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
@@ -1865,24 +1998,23 @@ async def chat_completion(
except asyncio.CancelledError:
log.info('Chat processing was cancelled')
try:
event_emitter = await get_event_emitter(metadata)
if event_emitter:
await asyncio.shield(
event_emitter(
{'type': 'chat:tasks:cancel'},
)
)
except Exception as e:
async def emit_cancel_event():
event_emitter = await get_event_emitter(metadata)
if event_emitter:
await event_emitter({'type': 'chat:tasks:cancel'})
await asyncio.shield(emit_cancel_event())
except Exception:
pass
finally:
raise # re-raise to ensure proper task cancellation handling
raise # re-raise to ensure proper task cancellation handling
except Exception as e:
error_detail = e.detail if isinstance(e, HTTPException) else str(e)
log.error('Error processing chat payload: %s', error_detail)
if metadata.get('chat_id') and metadata.get('message_id'):
# Update the chat message with the error
try:
if not metadata['chat_id'].startswith('local:'):
if not metadata['chat_id'].startswith('local:') and not metadata['chat_id'].startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@@ -1906,37 +2038,54 @@ async def chat_completion(
except Exception:
pass
else:
# No chat_id/message_id → legacy/direct API path with no
# WebSocket error channel. We must surface the error as
# a proper HTTP response; without this the function would
# return None which FastAPI serializes as null. #23924
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail,
)
finally:
# Clean up MCP clients. Shield the entire block from
# CancelledError so disconnect() can finish even when the
# task is being stopped. Each client is isolated so one
# Clean up MCP clients. Each client is isolated so one
# failure doesn't skip the rest.
#
# NOTE: asyncio.wait_for() / asyncio.shield() must NOT be used
# here — they create new asyncio Tasks, which violate anyio
# cancel-scope task-ownership rules when the MCPClient's
# exit_stack contains anyio transport resources (streamable_http).
# Exiting those cancel scopes from the wrong task raises
# "Attempted to exit a cancel scope that isn't the current
# task's current cancel scope", which propagates as a
# BaseException through the finally block, discards the response
# return value, and surfaces as a 500 "No response returned."
# MCPClient.disconnect() already catches BaseException internally.
try:
if mcp_clients := metadata.get('mcp_clients'):
async def _cleanup_mcp():
for client in reversed(list(mcp_clients.values())):
try:
await client.disconnect()
except Exception as e:
log.debug(f'Error disconnecting MCP client: {e}')
await asyncio.wait_for(
asyncio.shield(_cleanup_mcp()),
timeout=10.0,
)
except asyncio.TimeoutError:
log.warning('MCP client cleanup timed out after 10 s')
except Exception as e:
for client in reversed(list(mcp_clients.values())):
try:
await client.disconnect()
except BaseException as e:
log.debug(f'Error disconnecting MCP client: {e}')
except BaseException as e:
log.debug(f'Error cleaning up MCP clients: {e}')
# Emit chat:active=false when task completes
# Deregister this task, then emit chat:active=false if no others remain
try:
if metadata.get('chat_id'):
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
except Exception as e:
log.debug(f'Error emitting chat:active: {e}')
chat_id = metadata.get('chat_id')
task_id = metadata.get('task_id')
if chat_id and task_id:
await cleanup_task(request.app.state.redis, task_id, chat_id)
if not await has_active_tasks(request.app.state.redis, chat_id):
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
try:
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
except asyncio.CancelledError:
pass
except Exception:
pass
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
@@ -1984,6 +2133,7 @@ async def chat_completion(
),
id=chat_id,
)
per_model_metadata['task_id'] = task_id
task_ids.append(task_id)
# Emit chat:active=true
@@ -2127,7 +2277,7 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
@app.get('/api/tasks/chat/{chat_id:path}')
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:'):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
@@ -2145,7 +2295,7 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
@app.post('/api/tasks/chat/{chat_id:path}/stop')
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:'):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
@@ -2297,6 +2447,7 @@ async def get_app_config(request: Request):
'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE,
'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT,
'response_watermark': app.state.config.RESPONSE_WATERMARK,
'iframe_csp': IFRAME_CSP,
},
'license_metadata': app.state.LICENSE_METADATA,
**(
@@ -2690,6 +2841,14 @@ async def get_opensearch_xml():
return Response(content=xml_content, media_type='application/xml')
def _sync_db_ping() -> None:
ScopedSession.execute(text('SELECT 1;')).all()
async def async_db_ping() -> None:
await asyncio.to_thread(_sync_db_ping)
@app.get('/health')
async def healthcheck():
return {'status': True}
@@ -2711,7 +2870,7 @@ async def readiness_check():
# Check database connectivity
try:
ScopedSession.execute(text('SELECT 1;')).all()
await async_db_ping()
except Exception as e:
log.warning(f'Readiness check DB ping failed: {e!r}')
raise HTTPException(
@@ -2738,7 +2897,7 @@ async def readiness_check():
@app.get('/health/db')
async def healthcheck_with_db():
ScopedSession.execute(text('SELECT 1;')).all()
await async_db_ping()
return {'status': True}
@@ -2756,7 +2915,13 @@ async def serve_cache_file(
raise HTTPException(status_code=404, detail='File not found')
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail='File not found')
return FileResponse(file_path)
mime, _ = mimetypes.guess_type(file_path)
inline_safe = mime and mime.split('/', 1)[0] in {'image', 'audio', 'video'}
headers = {'X-Content-Type-Options': 'nosniff'}
if not inline_safe:
headers['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"'
return FileResponse(file_path, headers=headers)
def swagger_ui_html(*args, **kwargs):
+4 -4
View File
@@ -5,7 +5,7 @@ from alembic import context
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_mode_from_url, reattach_ssl_mode_to_url
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
# this is the Alembic Config object, which provides
@@ -37,9 +37,9 @@ target_metadata = Auth.metadata
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2, not asyncpg).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DB_URL)
DB_URL = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode) if ssl_mode else DB_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('%', '%%'))
@@ -0,0 +1,80 @@
"""add pinned_note table
Revision ID: 4de81c2a3af1
Revises: 56359461a091
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
# revision identifiers, used by Alembic.
revision: str = '4de81c2a3af1'
down_revision: Union[str, None] = '56359461a091'
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
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()
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:
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
conn = op.get_bind()
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
for note in notes:
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
op.drop_table('pinned_note')
@@ -0,0 +1,22 @@
"""Add memory user_id index
Revision ID: a0b1c2d3e4f5
Revises: 4de81c2a3af1
Create Date: 2025-09-15 03:00:00.000000
"""
from alembic import op
revision = 'a0b1c2d3e4f5'
down_revision = '4de81c2a3af1'
branch_labels = None
depends_on = None
def upgrade():
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
def downgrade():
op.drop_index('ix_memory_user_id', table_name='memory')
+7 -5
View File
@@ -395,14 +395,16 @@ class CalendarTable:
# Delete events
await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
# Delete access grants
await AccessGrants.revoke_all_access('calendar', id, db=db)
# Delete calendar
await db.execute(delete(Calendar).filter(Calendar.id == id))
await db.commit()
return True
except Exception:
# Revoke access grants in a separate transaction to avoid
# write-lock contention on SQLite when session sharing is off.
await AccessGrants.revoke_all_access('calendar', id)
return True
except Exception as e:
log.exception(f'Failed to delete calendar {id}: {e}')
return False
+10 -1
View File
@@ -4,6 +4,8 @@ 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
@@ -13,7 +15,7 @@ from open_webui.models.access_grants import (
AccessGrants,
)
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.dialects.postgresql import JSONB
@@ -244,6 +246,13 @@ class ChannelWebhookForm(BaseModel):
name: str
profile_image_url: Optional[str] = None
@field_validator('profile_image_url', mode='before')
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
return validate_profile_image_url(v)
class ChannelTable:
async def _get_access_grants(self, channel_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
+94 -28
View File
@@ -48,6 +48,25 @@ def get_usage(data: dict) -> Optional[dict]:
return normalize_usage(usage) if usage else None
def _token_columns(dialect: str):
"""Return (input_tokens, output_tokens) SQL column expressions.
Falls back to OpenAI-style keys (prompt_tokens / completion_tokens)
when the normalized keys are absent.
"""
if dialect == 'sqlite':
extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer)
elif dialect == 'postgresql':
extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect}')
return (
func.coalesce(extract('input_tokens'), extract('prompt_tokens')),
func.coalesce(extract('output_tokens'), extract('completion_tokens')),
)
####################
# ChatMessage DB Schema
####################
@@ -222,6 +241,79 @@ class ChatMessageTable:
messages = result.scalars().all()
return [ChatMessageModel.model_validate(message) for message in messages]
# DB column names that differ from the JSON message keys.
DB_TO_JSON_KEY_MAP = {
'parent_id': 'parentId',
'model_id': 'model',
'status_history': 'statusHistory',
'created_at': 'timestamp',
}
# DB-internal columns excluded from the reconstructed message dict.
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'})
async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
"""Build a {message_id: message_dict} map from chat_message rows.
Returns the same shape as chat.history.messages so callers
(get_message_list, middleware) work unchanged. Returns None if
no rows exist for the chat (caller should fall back to the
embedded JSON blob for legacy chats).
"""
async with get_async_db_context(db) as db:
result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id))
rows = result.scalars().all()
if not rows:
return None
# Strip the composite-id prefix ("{chat_id}-") to recover the
# original message_id used as map key.
prefix = f'{chat_id}-'
prefix_len = len(prefix)
col_keys = [c.key for c in ChatMessage.__table__.columns]
messages_map: dict[str, dict] = {}
for row in rows:
msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id
msg: dict = {'id': msg_id}
for key in col_keys:
if key in self.EXCLUDED_COLUMNS:
continue
val = getattr(row, key)
if val is None:
continue
json_key = self.DB_TO_JSON_KEY_MAP.get(key, key)
msg[json_key] = val
# Ensure content always has a value
msg.setdefault('content', '')
# Mirror usage into info.usage for callers that read it there
if 'usage' in msg:
msg['info'] = {'usage': msg['usage']}
messages_map[msg_id] = msg
# Reconstruct childrenIds from parentId links so that the map
# is fully navigable (callers like the frontend rely on this).
for msg_id, msg in messages_map.items():
parent_id = msg.get('parentId')
if parent_id and parent_id in messages_map:
parent = messages_map[parent_id]
children = parent.get('childrenIds')
if children is None:
parent['childrenIds'] = [msg_id]
elif msg_id not in children:
children.append(msg_id)
# Ensure every message has a childrenIds list (leaf nodes get [])
for msg in messages_map.values():
if 'childrenIds' not in msg:
msg['childrenIds'] = []
return messages_map
async def get_messages_by_user_id(
self,
user_id: str,
@@ -343,20 +435,7 @@ class ChatMessageTable:
bind = await db.connection()
dialect = bind.dialect.name
if dialect == 'sqlite':
input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer)
output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer)
elif dialect == 'postgresql':
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, 'input_tokens'),
Integer,
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, 'output_tokens'),
Integer,
)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect}')
input_tokens, output_tokens = _token_columns(dialect)
stmt = select(
ChatMessage.model_id,
@@ -404,20 +483,7 @@ class ChatMessageTable:
bind = await db.connection()
dialect = bind.dialect.name
if dialect == 'sqlite':
input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer)
output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer)
elif dialect == 'postgresql':
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, 'input_tokens'),
Integer,
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, 'output_tokens'),
Integer,
)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect}')
input_tokens, output_tokens = _token_columns(dialect)
stmt = select(
ChatMessage.user_id,
+84 -10
View File
@@ -366,20 +366,20 @@ class ChatTable:
await db.commit()
# Dual-write messages to chat_message table
try:
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get('history', {})
messages = history.get('messages', {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get('role'):
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_obj.id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(f'Failed to write imported messages to chat_message table: {e}')
except Exception as e:
log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}')
return [ChatModel.model_validate(chat) for chat in chats]
@@ -393,7 +393,6 @@ class ChatTable:
chat_item.updated_at = int(time.time())
await db.commit()
await db.refresh(chat_item)
return ChatModel.model_validate(chat_item)
except Exception:
@@ -460,12 +459,87 @@ class ChatTable:
return None
return row[0] or 'New Chat'
@staticmethod
def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
"""Return parent IDs referenced by messages but absent from the map.
An empty set means the message graph is fully connected.
"""
return {
msg['parentId']
for msg in messages_map.values()
if msg.get('parentId') and msg['parentId'] not in messages_map
}
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
"""Write messages to the ``chat_message`` table so future lookups
use the fast path. Errors are logged but never raised.
"""
for message_id, message in messages.items():
if not isinstance(message, dict) or not message.get('role'):
continue
try:
await ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
"""Message map for walking history (see ``get_message_list``).
Prefer ``chat_message`` rows to avoid loading the large embedded
history; fall back to the legacy JSON when no rows exist.
When rows exist but the parent-link graph has gaps (e.g. migration
failures), missing messages are merged from the legacy history
and backfilled so future requests self-heal.
"""
# Fast path: build from normalized chat_message rows.
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
if messages_map is not None:
unresolved_ids = self.get_unresolved_parent_ids(messages_map)
if not unresolved_ids:
return messages_map
# Graph has gaps — enrich from the legacy embedded history.
log.info(
'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history',
id,
len(unresolved_ids),
)
chat = await self.get_chat_by_id(id)
if chat:
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
missing_messages = {
message_id: history_messages[message_id]
for message_id in unresolved_ids
if message_id in history_messages
}
if missing_messages:
messages_map.update(missing_messages)
# Backfill so future requests use the fast path.
await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages)
return messages_map
# No rows — fall back to the legacy embedded history.
chat = await self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get('history', {}).get('messages', {}) or {}
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
# Backfill so future requests use the fast path.
if history_messages:
await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages)
return history_messages
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]:
chat = await self.get_chat_by_id(id)
+4 -2
View File
@@ -103,7 +103,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):
@@ -145,12 +146,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()),
}
+14 -4
View File
@@ -4,7 +4,7 @@ import time
from typing import Optional
import uuid
from sqlalchemy import select, delete, update, or_, func
from sqlalchemy import select, delete, update, or_, func, cast
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
@@ -313,11 +313,16 @@ class KnowledgeTable:
permission='read',
)
# Apply filename search
# Apply filename / content search
if filter:
q = filter.get('query')
if q:
stmt = stmt.filter(File.filename.ilike(f'%{q}%'))
stmt = stmt.filter(
or_(
File.filename.ilike(f'%{q}%'),
cast(File.data['content'], Text).ilike(f'%{q}%'),
)
)
# Order by file changes
stmt = stmt.order_by(File.updated_at.desc(), File.id.asc())
@@ -467,7 +472,12 @@ class KnowledgeTable:
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(or_(File.filename.ilike(f'%{query_key}%')))
stmt = stmt.filter(
or_(
File.filename.ilike(f'%{query_key}%'),
cast(File.data['content'], Text).ilike(f'%{query_key}%'),
)
)
view_option = filter.get('view_option')
if view_option == 'created':
+1 -1
View File
@@ -19,7 +19,7 @@ class Memory(Base):
__tablename__ = 'memory'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
user_id = Column(String, index=True)
content = Column(Text)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
+48
View File
@@ -143,6 +143,8 @@ class ModelAccessListResponse(BaseModel):
class ModelForm(BaseModel):
model_config = ConfigDict(extra='ignore')
id: str
base_model_id: Optional[str] = None
name: str
@@ -389,6 +391,52 @@ class ModelsTable:
return ModelListResponse(items=models, total=total)
async def get_model_meta_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[tuple[dict, int]]:
"""Return (meta, updated_at) for a model, skipping access grant resolution."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model.meta, Model.updated_at).filter_by(id=id))
return result.first()
except Exception:
return None
async def get_all_tags(
self,
user_id: str,
is_admin: bool = False,
db: Optional[AsyncSession] = None,
) -> set[str]:
"""Extract unique tag names from model meta, querying only the meta column."""
async with get_async_db_context(db) as db:
stmt = select(Model.meta).filter(Model.base_model_id != None)
if not is_admin:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
filter_dict = {'user_id': user_id}
if user_group_ids:
filter_dict['group_ids'] = user_group_ids
stmt = self._has_permission(db, stmt, filter_dict, permission='read')
result = await db.execute(stmt)
rows = result.scalars().all()
tags_set: set[str] = set()
for meta in rows:
if not meta:
continue
for tag in meta.get('tags', []):
try:
name = tag.get('name') if isinstance(tag, dict) else str(tag)
if name:
tags_set.add(name)
except Exception:
continue
return tags_set
async def get_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]:
try:
async with get_async_db_context(db) as db:
+40 -7
View File
@@ -13,7 +13,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, Text, JSON
from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey
####################
# Note DB Schema
@@ -29,7 +29,6 @@ class Note(Base):
title = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
is_pinned = Column(Boolean, default=False, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -52,6 +51,15 @@ class NoteModel(BaseModel):
updated_at: int # timestamp in epoch
class PinnedNote(Base):
__tablename__ = 'pinned_note'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
note_id = Column(Text, ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
created_at = Column(BigInteger, nullable=False)
####################
# Forms
####################
@@ -100,6 +108,7 @@ class NoteTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> NoteModel:
# We exclude access_grants to inject them
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db)
@@ -131,7 +140,7 @@ class NoteTable:
}
)
new_note = Note(**note.model_dump(exclude={'access_grants'}))
new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'}))
db.add(new_note)
await db.commit()
@@ -314,15 +323,28 @@ class NoteTable:
await db.commit()
return await self._to_note_model(note, db=db) if note else None
async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
async def toggle_note_pinned_by_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[NoteModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id))
note = result.scalars().first()
if not note:
return None
note.is_pinned = not note.is_pinned
note.updated_at = int(time.time_ns())
# Check if already pinned
pin_result = await db.execute(select(PinnedNote).filter_by(user_id=user_id, note_id=id))
pinned_note = pin_result.scalars().first()
if pinned_note:
await db.execute(delete(PinnedNote).filter_by(user_id=user_id, note_id=id))
else:
new_pin = PinnedNote(
id=str(uuid.uuid4()), user_id=user_id, note_id=id, created_at=int(time.time_ns())
)
db.add(new_pin)
await db.commit()
return await self._to_note_model(note, db=db)
except Exception:
@@ -338,7 +360,12 @@ class NoteTable:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc())
stmt = (
select(Note)
.join(PinnedNote, PinnedNote.note_id == Note.id)
.filter(PinnedNote.user_id == user_id)
.order_by(PinnedNote.created_at.desc())
)
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
result = await db.execute(stmt)
@@ -351,11 +378,17 @@ class NoteTable:
try:
async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('note', id, db=db)
await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id))
await db.execute(delete(Note).filter(Note.id == id))
await db.commit()
return True
except Exception:
return False
async def get_pinned_note_ids(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(PinnedNote.note_id).filter_by(user_id=user_id))
return result.scalars().all()
Notes = NoteTable()
@@ -320,6 +320,19 @@ class OAuthSessionTable:
log.error(f'Error deleting OAuth sessions by user ID: {e}')
return False
async def delete_sessions_by_user_id_and_provider(
self, user_id: str, provider: str, db: Optional[AsyncSession] = None
) -> bool:
"""Delete all OAuth sessions for a specific user and provider"""
try:
async with get_async_db_context(db) as db:
result = await db.execute(delete(OAuthSession).filter_by(user_id=user_id, provider=provider))
await db.commit()
return result.rowcount > 0
except Exception as e:
log.error(f'Error deleting OAuth sessions for user {user_id} and provider {provider}: {e}')
return False
async def delete_sessions_by_provider(self, provider: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all OAuth sessions for a provider"""
try:
+74 -21
View File
@@ -231,24 +231,51 @@ class PromptsTable:
async def get_prompts_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[PromptUserResponse]:
prompts = await self.get_prompts(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
result = []
for prompt in prompts:
if prompt.user_id == user_id:
result.append(prompt)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='prompt',
resource_id=prompt.id,
permission=permission,
user_group_ids=user_group_ids,
query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
query = AccessGrants.has_permission_filter(
db=db,
):
result.append(prompt)
return result
query=query,
DocumentModel=Prompt,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='prompt',
permission=permission,
)
result = await db.execute(query)
accessible_prompts = result.scalars().all()
if not accessible_prompts:
return []
prompt_ids = [p.id for p in accessible_prompts]
owner_ids = list({p.user_id for p in accessible_prompts})
users = await Users.get_users_by_user_ids(owner_ids, db=db)
users_dict = {u.id: u for u in users}
grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db)
results = []
for prompt in accessible_prompts:
user = users_dict.get(prompt.user_id)
results.append(
PromptUserResponse.model_validate(
{
**(
await self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
)
).model_dump(),
'user': user.model_dump() if user else None,
}
)
)
return results
async def search_prompts(
self,
@@ -632,12 +659,38 @@ class PromptsTable:
async def get_tags(self, db: Optional[AsyncSession] = None) -> list[str]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(is_active=True))
prompts = result.scalars().all()
result = await db.execute(select(Prompt.tags).filter(Prompt.is_active == True))
tags = set()
for prompt in prompts:
if prompt.tags:
for tag in prompt.tags:
for (tag_list,) in result.all():
if tag_list:
for tag in tag_list:
if tag:
tags.add(tag)
return sorted(list(tags))
except Exception:
return []
async def get_tags_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
try:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
query = select(Prompt.tags).filter(Prompt.is_active == True)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='prompt',
permission='read',
)
result = await db.execute(query)
tags = set()
for (tag_list,) in result.all():
if tag_list:
for tag in tag_list:
if tag:
tags.add(tag)
return sorted(list(tags))
+9 -2
View File
@@ -23,9 +23,9 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY, AIOHTTP_CLIENT_SESSION_SSL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@@ -205,6 +205,7 @@ class DoclingLoader:
**self.params,
},
headers=headers,
verify=AIOHTTP_CLIENT_SESSION_SSL,
)
if r.ok:
result = r.json()
@@ -399,6 +400,12 @@ class Loader:
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
file_path=file_path,
)
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
loader = PaddleOCRVLLoader(
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
file_path=file_path,
)
else:
if file_ext == 'pdf':
loader = PyPDFLoader(
@@ -0,0 +1,125 @@
import base64
import os
import requests
import logging
import sys
from typing import List
from langchain_core.documents import Document
from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
class PaddleOCRVLLoader:
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
def __init__(
self,
api_url: str,
token: str,
file_path: str,
):
if not api_url or not token:
raise ValueError('PaddleOCR-vl API URL and Token are required.')
if not os.path.exists(file_path):
raise FileNotFoundError(f'File not found at {file_path}')
self.api_url = api_url.rstrip('/')
self.token = token
self.file_path = file_path
self.file_name = os.path.basename(file_path)
def load(self) -> List[Document]:
log.info(f'Processing with PaddleOCR-vl: {self.file_path}')
try:
with open(self.file_path, 'rb') as file:
file_bytes = file.read()
file_data = base64.b64encode(file_bytes).decode('ascii')
except Exception as e:
log.error(f'Failed to read file {self.file_path}: {e}')
raise
headers = {'Authorization': f'token {self.token}', 'Content-Type': 'application/json'}
# Detect fileType based on file extension
ext = self.file_path.lower().split('.')[-1]
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
file_type = 1 if ext in image_extensions else 0
payload = {
'file': file_data,
'fileType': file_type,
'useDocOrientationClassify': False,
'useDocUnwarping': False,
'useChartRecognition': False,
}
try:
response = requests.post(f'{self.api_url}/layout-parsing', json=payload, headers=headers)
response.raise_for_status()
result = response.json().get('result', {})
layout_results = result.get('layoutParsingResults', [])
documents = []
total_pages = len(layout_results)
skipped_pages = 0
for i, res in enumerate(layout_results):
markdown_text = res.get('markdown', {}).get('text', '')
if isinstance(markdown_text, str):
cleaned_content = markdown_text.strip()
else:
cleaned_content = str(markdown_text).strip()
if not cleaned_content:
skipped_pages += 1
continue
documents.append(
Document(
page_content=cleaned_content,
metadata={
'page': i,
'page_label': i + 1,
'total_pages': total_pages,
'file_name': self.file_name,
'processing_engine': 'paddleocr-vl',
},
)
)
if skipped_pages > 0:
log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.')
if not documents:
log.warning('No valid text content found by PaddleOCR-vl.')
return [
Document(
page_content='No valid text content found in document',
metadata={
'error': 'no_valid_pages',
'file_name': self.file_name,
'processing_engine': 'paddleocr-vl',
},
)
]
return documents
except Exception as e:
log.error(f'Error calling PaddleOCR-vl: {e}')
return [
Document(
page_content=f'Error during OCR processing: {e}',
metadata={
'error': 'processing_failed',
'file_name': self.file_name,
'processing_engine': 'paddleocr-vl',
},
)
]
+15 -1
View File
@@ -43,6 +43,7 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
AIOHTTP_CLIENT_SESSION_SSL,
@@ -114,6 +115,8 @@ def build_loader_from_config(request):
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL,
PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN,
MINERU_API_MODE=config.MINERU_API_MODE,
MINERU_API_URL=config.MINERU_API_URL,
MINERU_API_KEY=config.MINERU_API_KEY,
@@ -178,8 +181,12 @@ def get_content_from_url(request, url: str) -> str:
validate_url(url)
# Streamed GET to check Content-Type without downloading the body.
# allow_redirects=False prevents redirect-based SSRF: validate_url() above is
# called on the originally-submitted URL only; following 3xx redirects without
# re-validation would let an attacker reach private IPs (RFC1918, loopback,
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
try:
response = requests.get(url, stream=True, timeout=30)
response = requests.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
except Exception:
@@ -564,6 +571,13 @@ async def query_collection(
log.exception(f'Error when querying the collection: {e}')
return None, e
# Sanitize: filter out None/empty queries to prevent embedding crashes
# (e.g. when get_last_user_message returns None)
queries = [q for q in queries if q]
if not queries:
log.warning('query_collection: all queries were None or empty, returning empty results')
return {'distances': [[]], 'documents': [[]], 'metadatas': [[]]}
# Generate all query embeddings (in one call)
query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections')
@@ -0,0 +1,66 @@
import logging
import time
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
def search_brave_llm_context(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
context_tokens: int = 8192,
) -> list[SearchResult]:
"""Search using Brave's LLM Context API and return pre-extracted, relevance-scored
page content ready for LLM consumption.
Uses /res/v1/llm/context instead of /res/v1/web/search. Same API key, same pricing.
Returns full extracted passages per URL rather than short snippets, eliminating
the need for post-search scraping.
Args:
api_key (str): A Brave Search API key (same key as web search)
query (str): The query to search for
count (int): Maximum number of results to return
filter_list (list[str], optional): Domain filter list
context_tokens (int): Maximum total tokens to retrieve (1024–32768, default 8192)
"""
url = 'https://api.search.brave.com/res/v1/llm/context'
headers = {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': api_key,
}
params = {
'q': query,
'count': count,
'maximum_number_of_tokens': context_tokens,
}
response = requests.get(url, headers=headers, params=params)
# Handle 429 rate limiting - same rate limits as web search
if response.status_code == 429:
log.info('Brave LLM Context API rate limited (429), retrying after 1 second...')
time.sleep(1)
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
results = json_response.get('grounding', {}).get('generic', [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result['url'],
title=result.get('title'),
snippet='\n\n'.join(result.get('snippets', [])),
)
for result in results[:count]
]
+12 -3
View File
@@ -1,4 +1,5 @@
import logging
import urllib.request
from typing import Optional
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
@@ -25,17 +26,25 @@ def search_duckduckgo(
Returns:
list[SearchResult]: A list of search results
"""
# Use the DDGS context manager to create a DDGS object
# The ddgs library (primp-based) does not auto-detect proxy env vars.
# Resolve via stdlib getproxies() — same pattern as the other loaders.
env_proxies = urllib.request.getproxies()
proxy = env_proxies.get('https') or env_proxies.get('http')
search_results = []
with DDGS() as ddgs:
with DDGS(proxy=proxy) as ddgs:
if concurrent_requests:
ddgs.threads = concurrent_requests
# Use the ddgs.text() method to perform the search
try:
search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend)
kwargs = {'safesearch': 'moderate', 'max_results': count}
if backend and backend != 'auto':
kwargs['backend'] = backend
results = ddgs.text(query, **kwargs)
search_results = results if results is not None else []
except RatelimitException as e:
log.error(f'RatelimitException: {e}')
search_results = []
if filter_list:
search_results = get_filtered_results(search_results, filter_list)
+202 -25
View File
@@ -1,52 +1,229 @@
from __future__ import annotations
import logging
from typing import Optional, List
import time
from typing import TYPE_CHECKING, Any
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from langchain_core.documents import Document
if TYPE_CHECKING:
from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__)
DEFAULT_FIRECRAWL_API_BASE_URL = 'https://api.firecrawl.dev'
FIRECRAWL_RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
FIRECRAWL_MAX_RETRIES = 2
def build_firecrawl_url(base_url: str | None, path: str) -> str:
base_url = (base_url or DEFAULT_FIRECRAWL_API_BASE_URL).rstrip('/')
path = path.lstrip('/')
if base_url.endswith('/v2'):
return f'{base_url}/{path}'
return f'{base_url}/v2/{path}'
def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key or ""}',
}
def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:
if timeout in (None, ''):
return None
try:
timeout = float(timeout)
except (TypeError, ValueError):
return None
return timeout if timeout > 0 else None
def get_firecrawl_scrape_timeout_ms(timeout: Any) -> int | None:
timeout_seconds = get_firecrawl_timeout_seconds(timeout)
if timeout_seconds is None:
return None
# Firecrawl v2 expects scrape timeouts in milliseconds.
return min(300000, max(1000, int(timeout_seconds * 1000)))
def get_firecrawl_client_timeout_seconds(timeout: Any, fallback: float = 60) -> float:
# Keep the local HTTP timeout slightly above Firecrawl's scrape timeout.
return (get_firecrawl_timeout_seconds(timeout) or fallback) + 10
def get_firecrawl_retry_delay(headers: Any, attempt: int) -> float:
retry_after = headers.get('Retry-After') if headers else None
if retry_after:
try:
return min(10.0, max(0.0, float(retry_after)))
except (TypeError, ValueError):
pass
return min(8.0, float(2**attempt))
def request_firecrawl_json(
method: str,
url: str,
*,
headers: dict[str, str],
json: dict[str, Any] | None = None,
timeout: float | None = None,
verify: bool = True,
) -> dict[str, Any]:
last_error = None
for attempt in range(FIRECRAWL_MAX_RETRIES + 1):
try:
response = requests.request(
method,
url,
headers=headers,
json=json,
timeout=timeout,
verify=verify,
)
if response.status_code in FIRECRAWL_RETRY_STATUS_CODES and attempt < FIRECRAWL_MAX_RETRIES:
delay = get_firecrawl_retry_delay(response.headers, attempt)
log.warning(
'Firecrawl %s %s returned HTTP %s; retrying in %.1fs',
method,
url,
response.status_code,
delay,
)
time.sleep(delay)
continue
response.raise_for_status()
return response.json()
except (requests.ConnectionError, requests.Timeout) as e:
last_error = e
if attempt >= FIRECRAWL_MAX_RETRIES:
break
delay = get_firecrawl_retry_delay(None, attempt)
log.warning('Firecrawl %s %s failed; retrying in %.1fs: %s', method, url, delay, e)
time.sleep(delay)
if last_error:
raise last_error
raise RuntimeError(f'Firecrawl {method} {url} failed without a response')
def get_firecrawl_result_url(result: dict[str, Any]) -> str:
metadata = result.get('metadata') or {}
return (
result.get('url')
or result.get('link')
or metadata.get('url')
or metadata.get('sourceURL')
or metadata.get('source_url')
or ''
)
def scrape_firecrawl_url(
firecrawl_url: str,
firecrawl_api_key: str,
url: str,
*,
verify_ssl: bool = True,
timeout: Any = None,
params: dict[str, Any] | None = None,
) -> Document | None:
payload = {
'url': url,
'formats': ['markdown'],
'skipTlsVerification': not verify_ssl,
'removeBase64Images': True,
**(params or {}),
}
scrape_timeout_ms = get_firecrawl_scrape_timeout_ms(timeout)
if scrape_timeout_ms is not None:
payload['timeout'] = scrape_timeout_ms
response = request_firecrawl_json(
'POST',
build_firecrawl_url(firecrawl_url, 'scrape'),
headers=build_firecrawl_headers(firecrawl_api_key),
json=payload,
timeout=get_firecrawl_client_timeout_seconds(timeout),
verify=verify_ssl,
)
data = response.get('data') or {}
content = data.get('markdown') or ''
if not isinstance(content, str) or not content.strip():
return None
metadata = data.get('metadata') or {}
document_metadata = {'source': get_firecrawl_result_url(data) or url}
if metadata.get('title'):
document_metadata['title'] = metadata['title']
if metadata.get('description'):
document_metadata['description'] = metadata['description']
return Document(page_content=content, metadata=document_metadata)
def search_firecrawl(
firecrawl_url: str,
firecrawl_api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
) -> List[SearchResult]:
filter_list: list[str] | None = None,
) -> list[SearchResult]:
try:
url = firecrawl_url.rstrip('/')
response = requests.post(
f'{url}/v1/search',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {firecrawl_api_key}',
},
response = request_firecrawl_json(
'POST',
build_firecrawl_url(firecrawl_url, 'search'),
headers=build_firecrawl_headers(firecrawl_api_key),
json={
'query': query,
'limit': count,
'timeout': count * 3000,
'ignoreInvalidURLs': True,
},
timeout=count * 3 + 10,
)
response.raise_for_status()
data = response.json().get('data', {})
results = [
SearchResult(
link=r.get('url', ''),
title=r.get('title', ''),
snippet=r.get('description', ''),
)
for r in data.get('web', [])
]
data = response.get('data') or {}
results = data.get('web') or []
if filter_list:
from open_webui.retrieval.web.main import get_filtered_results
results = get_filtered_results(results, filter_list)
results = results[:count]
log.info(f'FireCrawl search results: {results}')
return results
from open_webui.retrieval.web.main import SearchResult
search_results = []
for result in results[:count]:
url = get_firecrawl_result_url(result)
if not url:
continue
metadata = result.get('metadata') or {}
search_results.append(
SearchResult(
link=url,
title=result.get('title') or metadata.get('title'),
snippet=result.get('description') or result.get('snippet') or metadata.get('description'),
)
)
log.info(f'FireCrawl search results: {search_results}')
return search_results
except Exception as e:
log.error(f'Error in FireCrawl search: {e}')
return []
+1 -1
View File
@@ -38,7 +38,7 @@ def search_searxng(
"""
# Default values for optional parameters are provided as empty strings or None when not specified.
language = kwargs.get('language', 'all')
language = kwargs.get('language', 'all').strip().rstrip(',')
safesearch = kwargs.get('safesearch', '1')
time_range = kwargs.get('time_range', '')
categories = ''.join(kwargs.get('categories', []))
+35 -32
View File
@@ -5,6 +5,8 @@ import socket
import ssl
import urllib.parse
import urllib.request
import requests
from datetime import datetime, time, timedelta
from typing import (
Any,
@@ -28,6 +30,7 @@ from langchain_core.documents import Document
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
@@ -45,7 +48,7 @@ from open_webui.config import (
WEB_FETCH_FILTER_LIST,
)
from open_webui.utils.misc import is_string_allowed
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS
log = logging.getLogger(__name__)
@@ -66,6 +69,14 @@ def validate_url(url: Union[str, Sequence[str]]):
if isinstance(validators.url(url), validators.ValidationError):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
# Reject parser-confusing chars: urlparse and requests/aiohttp split
# on these differently, e.g. http://127.0.0.1\@1.1.1.1 → urlparse
# extracts 1.1.1.1 (public, passes filter) while requests connects
# to 127.0.0.1 (internal). Same shape with tab/CR/LF.
if any(ch in url for ch in ('\\', '\t', '\n', '\r')):
log.warning(f'Blocked URL with parser-confusing char: {url!r}')
raise ValueError(ERROR_MESSAGES.INVALID_URL)
parsed_url = urllib.parse.urlparse(url)
# Protocol validation - only allow http/https
@@ -216,39 +227,20 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def lazy_load(self) -> Iterator[Document]:
try:
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
}
for url in self.web_paths:
payload = {
'url': url,
'formats': ['markdown'],
**self.params,
}
if self.timeout:
payload['timeout'] = self.timeout * 1000
response = requests.post(
f'{self.api_url}/v1/scrape',
headers=headers,
json=payload,
timeout=self.timeout or 60,
verify=self.verify_ssl,
)
response.raise_for_status()
data = response.json().get('data', {})
metadata = data.get('metadata', {})
source = metadata.get('url') or metadata.get('sourceURL') or url
yield Document(
page_content=data.get('markdown', ''),
metadata={'source': source},
doc = scrape_firecrawl_url(
self.api_url,
self.api_key,
url,
verify_ssl=self.verify_ssl,
timeout=self.timeout,
params=self.params,
)
if doc is not None:
yield doc
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error extracting content from URLs: {e}')
log.warning(f'Error extracting content from URLs with Firecrawl: {e}')
else:
raise e
@@ -259,7 +251,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
yield doc
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error extracting content from URLs: {e}')
log.warning(f'Error extracting content from URLs with Firecrawl: {e}')
else:
raise e
@@ -501,6 +493,17 @@ class SafeWebBaseLoader(WebBaseLoader):
"""
super().__init__(*args, **kwargs)
self.trust_env = trust_env
# Prevent redirect-based SSRF on the synchronous _scrape() path.
# validate_url() is called once on the originally-submitted URL, but the
# parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs)
# which by default follows redirects. Without the override below, an attacker
# can submit a public URL that 302-redirects to an internal address (RFC1918,
# 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without
# re-validation. Matches the policy enforced on the async _fetch() path below.
self.requests_kwargs = {
**(self.requests_kwargs or {}),
'allow_redirects': AIOHTTP_CLIENT_ALLOW_REDIRECTS,
}
async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str:
async with aiohttp.ClientSession(trust_env=self.trust_env) as session:
@@ -518,7 +521,7 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get(
url,
**(self.requests_kwargs | kwargs),
allow_redirects=False,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as response:
if self.raise_for_status:
response.raise_for_status()
@@ -20,6 +20,8 @@ log = logging.getLogger(__name__)
def xml_element_contents_to_string(element: Element) -> str:
if element is None:
return ''
buffer = [element.text if element.text else '']
for child in element:
+91 -13
View File
@@ -1,3 +1,5 @@
import asyncio
import io
import hashlib
import json
import logging
@@ -127,6 +129,54 @@ def convert_audio_to_mp3(file_path):
return None
def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_path: str) -> bool:
"""
Transcode audio bytes to MP3 if the Content-Type indicates a non-MP3 format.
Handles raw PCM audio (e.g. Gemini-TTS via OpenRouter/LiteLLM) by parsing
optional rate/channels from the Content-Type params, defaulting to 24kHz,
16-bit, mono. For other non-MP3 formats, uses pydub auto-detection.
Returns True if transcoding was performed, False if the data is already MP3.
Respects BYPASS_PYDUB_PREPROCESSING — when set, writes raw bytes and logs a warning.
"""
mime_type = content_type_header.split(';')[0].strip().lower()
if mime_type in ('audio/mpeg', 'audio/mp3'):
return False
if BYPASS_PYDUB_PREPROCESSING:
log.warning(
f'TTS returned {mime_type} but BYPASS_PYDUB_PREPROCESSING is set; writing raw audio without transcoding'
)
return False
if mime_type in ('audio/pcm', 'audio/l16', 'audio/raw'):
# Parse optional rate/channels from Content-Type params,
# default: 24kHz, 16-bit, mono (standard for Gemini TTS).
ct_params = {}
for part in content_type_header.split(';')[1:]:
key_val = part.strip().split('=')
if len(key_val) == 2:
ct_params[key_val[0].strip().lower()] = key_val[1].strip()
sample_rate = int(ct_params.get('rate', 24000))
channels = int(ct_params.get('channels', 1))
audio_segment = AudioSegment.from_raw(
io.BytesIO(audio_data),
sample_width=2,
frame_rate=sample_rate,
channels=channels,
)
else:
audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
audio_segment.export(str(output_path), format='mp3')
log.info(f'Transcoded {mime_type} audio to MP3: {output_path}')
return True
def set_faster_whisper_model(model: str, auto_update: bool = False):
whisper_model = None
if model:
@@ -178,6 +228,7 @@ class STTConfigForm(BaseModel):
ENGINE: str
MODEL: str
SUPPORTED_CONTENT_TYPES: list[str] = []
ALLOWED_EXTENSIONS: list[str] = []
WHISPER_MODEL: str
DEEPGRAM_API_KEY: str
AZURE_API_KEY: str
@@ -219,6 +270,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
'ENGINE': request.app.state.config.STT_ENGINE,
'MODEL': request.app.state.config.STT_MODEL,
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
@@ -254,6 +306,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
request.app.state.config.STT_MODEL = form_data.stt.MODEL
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES
request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
@@ -295,6 +348,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
'ENGINE': request.app.state.config.STT_ENGINE,
'MODEL': request.app.state.config.STT_MODEL,
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
@@ -387,8 +441,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
r.raise_for_status()
async with aiofiles.open(file_path, 'wb') as f:
await f.write(await r.read())
audio_data = await r.read()
content_type_header = r.headers.get('Content-Type', 'audio/mpeg')
if not transcode_audio_to_mp3(audio_data, content_type_header, file_path):
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_data)
async with aiofiles.open(file_body_path, 'w') as f:
await f.write(json.dumps(payload))
@@ -576,7 +634,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
mistral_payload = {
'input': payload.get('input', ''),
'model': request.app.state.config.TTS_MODEL or 'mistral-tts-latest',
'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603',
'voice_id': payload.get('voice', ''),
'response_format': 'mp3',
}
@@ -1107,6 +1165,11 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
else:
if is_audio_conversion_required(file_path):
file_path = convert_audio_to_mp3(file_path)
if not file_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Audio conversion failed. The audio file may be corrupted or empty.',
)
try:
file_path = compress_audio(file_path)
@@ -1126,7 +1189,12 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
results = []
try:
with ThreadPoolExecutor() as executor:
if getattr(request.app.state.config, 'STT_ENGINE', '') == '':
max_workers = 1
else:
max_workers = None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks for each chunk_path
futures = [
executor.submit(transcription_handler, request, chunk_path, metadata, user)
@@ -1242,12 +1310,19 @@ async def transcription(
try:
safe_name = os.path.basename(file.filename) if file.filename else ''
ext = safe_name.rsplit('.', 1)[-1] if '.' in safe_name else ''
ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else ''
allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', [])
if allowed_extensions and ext not in allowed_extensions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid audio file extension',
)
id = uuid.uuid4()
filename = f'{id}.{ext}'
contents = file.file.read()
contents = await file.read()
file_dir = os.path.join(CACHE_DIR, 'audio', 'transcriptions')
os.makedirs(file_dir, exist_ok=True)
@@ -1266,7 +1341,7 @@ async def transcription(
if language:
metadata = {'language': language}
result = transcribe(request, file_path, metadata, user)
result = await asyncio.to_thread(transcribe, request, file_path, metadata, user)
return {
**result,
@@ -1345,7 +1420,7 @@ async def get_available_models(request: Request) -> list[dict]:
except Exception as e:
log.error(f'Error fetching models: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
available_models = [{'id': 'mistral-tts-latest'}]
available_models = [{'id': 'voxtral-mini-tts-2603'}]
return available_models
@@ -1431,11 +1506,14 @@ async def get_available_voices(request) -> dict:
response.raise_for_status()
voices_data = await response.json()
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
# Mistral returns a paginated response: {"items": [...], "page": ..., "total": ...}
voices_list = voices_data.get('items', []) if isinstance(voices_data, dict) else voices_data
for voice in voices_list:
if isinstance(voice, dict):
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except Exception as e:
log.error(f'Error fetching Mistral voices: {str(e)}')
+51 -6
View File
@@ -172,10 +172,17 @@ async def get_session_user(
user=Depends(get_current_user),
db: AsyncSession = Depends(get_async_session),
):
token = None
auth_header = request.headers.get('Authorization')
auth_token = get_http_authorization_cred(auth_header)
token = auth_token.credentials
data = decode_token(token)
if auth_header:
auth_token = get_http_authorization_cred(auth_header)
if auth_token is not None:
token = auth_token.credentials
if token is None:
token = request.cookies.get('token')
if token is None and getattr(request.state, 'token', None):
token = request.state.token.credentials
data = decode_token(token) if token else None
expires_at = None
@@ -515,6 +522,18 @@ async def ldap_auth(
db=db,
)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
'action': 'signup',
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
'user': user.model_dump_json(exclude_none=True),
},
)
except HTTPException:
raise
except Exception as err:
@@ -766,15 +785,16 @@ async def signup(
raise HTTPException(500, detail='An internal error occurred during signup.')
@router.get('/signout')
@router.post('/signout')
async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
# get auth token from headers or cookies
token = None
auth_header = request.headers.get('Authorization')
if auth_header:
auth_cred = get_http_authorization_cred(auth_header)
token = auth_cred.credentials
else:
if auth_cred is not None:
token = auth_cred.credentials
if token is None:
token = request.cookies.get('token')
if token:
@@ -853,6 +873,31 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
return JSONResponse(status_code=200, content={'status': True}, headers=response.headers)
############################
# OAuth Session Management
############################
@router.delete('/oauth/sessions/{provider:path}', response_model=bool)
async def delete_oauth_session_by_provider(
provider: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""
Disconnect the current user's OAuth session for a specific provider.
The provider string matches the 'provider' field in the oauth_session table
(e.g. 'mcp:server-id' for MCP connections).
"""
result = await OAuthSessions.delete_sessions_by_user_id_and_provider(user.id, provider, db=db)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No OAuth session found for this provider',
)
return True
############################
# AddUser
############################
+26 -1
View File
@@ -22,7 +22,7 @@ from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.calendar import expand_recurring_event
from open_webui.constants import ERROR_MESSAGES
@@ -112,6 +112,17 @@ async def get_calendars(request: Request, user: UserModel = Depends(get_verified
async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)):
"""Create a new user calendar."""
await check_calendar_permission(request, user)
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/models pattern). Without this, any verified user
# could create a calendar with `principal_id='*' permission='read'|'write'`,
# making their events readable or writable by any other verified user.
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
)
return await Calendars.insert_new_calendar(user.id, form_data)
@@ -350,6 +361,20 @@ async def update_calendar(
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/models pattern). The owner-only check above
# only restricts WHO can set grants; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their calendar
# publicly readable/writable without the corresponding sharing permission.
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
)
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
if not updated:
raise HTTPException(status_code=500, detail='Failed to update')
+40 -43
View File
@@ -57,7 +57,6 @@ from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user
@@ -979,57 +978,48 @@ async def model_response_handler(request, channel, message, user, db=None):
],
]
# Resolve model config (same helpers automations use)
from open_webui.utils.automations import (
_resolve_model_tool_ids,
_resolve_model_features,
_resolve_model_filter_ids,
)
tool_ids = _resolve_model_tool_ids(request.app, model_id)
features = _resolve_model_features(request.app, model_id)
filter_ids = _resolve_model_filter_ids(request.app, model_id)
# Build full form_data — same shape as frontend POST.
# The channel: prefix routes pipeline events to the
# channel emitter in socket/main.py instead of the
# default chat emitter.
form_data = {
'model': model_id,
'messages': [
system_message,
{'role': 'user', 'content': content},
],
'stream': False,
'stream': True,
'chat_id': f'channel:{channel.id}',
'id': response_message.id,
'session_id': f'channel:{channel.id}',
'background_tasks': {},
}
if tool_ids:
form_data['tool_ids'] = tool_ids
if features:
form_data['features'] = features
if filter_ids:
form_data['filter_ids'] = filter_ids
res = await generate_chat_completion(
request,
form_data=form_data,
user=user,
)
# Call the full chat completion pipeline — streaming,
# tools, filters, RAG — everything. The pipeline runs as
# an async task; the channel emitter handles progressive
# message updates via socket events.
await request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
if res:
if res.get('choices', []) and len(res['choices']) > 0:
await update_message_by_id(
request,
channel.id,
response_message.id,
MessageForm(
**{
'content': res['choices'][0]['message']['content'],
'meta': {
'done': True,
},
}
),
user,
db,
)
elif res.get('error', None):
await update_message_by_id(
request,
channel.id,
response_message.id,
MessageForm(
**{
'content': f'Error: {res["error"]}',
'meta': {
'done': True,
},
}
),
user,
db,
)
except Exception as e:
log.info(e)
pass
log.exception(e)
return True
@@ -1256,7 +1246,8 @@ async def pin_channel_message(
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db):
# Pin/unpin mutates is_pinned/pinned_by/pinned_at — require write.
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='write', db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
message = await Messages.get_message_by_id(message_id, db=db)
@@ -1368,6 +1359,9 @@ async def update_message_by_id(
if channel.type in ['group', 'dm']:
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Membership is not authorship — block cross-member edits.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
@@ -1569,6 +1563,9 @@ async def delete_message_by_id(
if channel.type in ['group', 'dm']:
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Membership is not authorship — block cross-member deletes.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
+81 -40
View File
@@ -8,6 +8,7 @@ from fastapi.responses import StreamingResponse
from open_webui.utils.misc import get_message_list
from open_webui.utils.middleware import serialize_output
from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
@@ -676,11 +677,46 @@ async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSessio
# GetChats
############################
CHAT_EXPORT_BATCH_SIZE = 100
@router.get('/all', response_model=list[ChatResponse])
async def get_user_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
result = await Chats.get_chats_by_user_id(user.id, db=db)
return [ChatResponse(**chat.model_dump()) for chat in result.items]
async def generate_chat_export_ndjson(user_id: str):
"""
Async generator that streams all user chats as NDJSON (one JSON object per line).
Uses short-lived DB sessions per batch to avoid holding locks for the
entire duration, which is critical for SQLite environments.
"""
skip = 0
while True:
result = await Chats.get_chats_by_user_id(
user_id,
skip=skip,
limit=CHAT_EXPORT_BATCH_SIZE,
db=None,
)
if not result.items:
break
for chat in result.items:
try:
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
except Exception as e:
log.exception(f'Error serializing chat {chat.id}: {e}')
if len(result.items) < CHAT_EXPORT_BATCH_SIZE:
break
skip += CHAT_EXPORT_BATCH_SIZE
@router.get('/all')
async def get_user_chats(user=Depends(get_verified_user)):
return StreamingResponse(
generate_chat_export_ndjson(user.id),
media_type='application/x-ndjson',
)
############################
@@ -829,29 +865,31 @@ async def get_shared_chat_by_id(
if user.role == 'pending':
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_share_id(share_id, db=db)
# Fallback: admins can also access any chat directly by chat ID
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(share_id, db=db)
else:
chat = await Chats.get_chat_by_share_id(share_id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
# Look up the original chat_id to check access grants
shared = await SharedChats.get_by_id(share_id, db=db)
if shared:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
# Look up the original chat_id to check access grants (admins bypass)
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
shared = await SharedChats.get_by_id(share_id, db=db)
if shared and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return ChatResponse(**chat.model_dump())
@@ -930,6 +968,14 @@ async def update_chat_by_id(
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
updated_chat = {**chat.chat, **form_data.chat}
# Re-derive content from output for assistant messages so that
# frontend edits to output items are always reflected in content.
# serialize_output() is the single source of truth for this conversion.
for msg in updated_chat.get('history', {}).get('messages', {}).values():
if msg.get('role') == 'assistant' and msg.get('output'):
msg['content'] = serialize_output(msg['output'])
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
return ChatResponse(**chat.model_dump())
else:
@@ -1183,10 +1229,11 @@ async def clone_chat_by_id(
async def clone_shared_chat_by_id(
id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
if user.role == 'admin':
chat = await Chats.get_chat_by_share_id(id, db=db)
# Fallback: admins can also access any chat directly by chat ID
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
else:
chat = await Chats.get_chat_by_share_id(id, db=db)
if not chat:
raise HTTPException(
@@ -1194,9 +1241,9 @@ async def clone_shared_chat_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Enforce access grants
# Enforce access grants (owner and admins bypass)
shared = await SharedChats.get_by_id(id, db=db)
if shared and user.role != 'admin':
if shared and user.role != 'admin' and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
@@ -1365,19 +1412,16 @@ async def update_shared_chat_access_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
else:
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
@@ -1402,19 +1446,16 @@ async def get_shared_chat_access_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
else:
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
return [
{
+19 -6
View File
@@ -8,6 +8,7 @@ from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers
from open_webui.config import get_config, save_config, async_save_config
from open_webui.config import BannerModel
@@ -49,8 +50,9 @@ class ImportConfigForm(BaseModel):
@router.post('/import', response_model=dict)
async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)):
async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)):
await async_save_config(form_data.config)
request.app.state.config._sync_to_redis()
return get_config()
@@ -102,6 +104,7 @@ class OAuthClientRegistrationForm(BaseModel):
client_id: str
client_name: Optional[str] = None
client_secret: Optional[str] = None
oauth_server_url: Optional[str] = None
@router.post('/oauth/clients/register')
@@ -116,18 +119,20 @@ async def register_oauth_client(
if type:
oauth_client_id = f'{type}:{form_data.client_id}'
oauth_server_url = form_data.oauth_server_url if form_data.oauth_server_url else form_data.url
if form_data.client_secret:
# Static credentials: skip dynamic registration, build from provided credentials
oauth_client_info = await get_oauth_client_info_with_static_credentials(
request,
oauth_client_id,
form_data.url,
oauth_server_url,
oauth_client_id=form_data.client_id,
oauth_client_secret=form_data.client_secret,
)
else:
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
request, oauth_client_id, form_data.url
request, oauth_client_id, oauth_server_url
)
return {
'status': True,
@@ -154,6 +159,7 @@ class ToolServerConnection(BaseModel):
headers: Optional[dict | str] = None
key: Optional[str]
config: Optional[dict]
info: Optional[dict] = None
model_config = ConfigDict(extra='allow')
@@ -368,7 +374,12 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
try:
if form_data.type == 'mcp':
if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'):
discovery_urls = await get_discovery_urls(form_data.url)
oauth_server_url = (
form_data.info.get('oauth_server_url')
if form_data.info and form_data.info.get('oauth_server_url')
else form_data.url
)
discovery_urls = await get_discovery_urls(oauth_server_url)
for discovery_url in discovery_urls:
log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}')
async with aiohttp.ClientSession(
@@ -427,7 +438,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
headers.update(form_data.headers)
custom_headers = get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
await client.connect(form_data.url, headers=headers)
specs = await client.list_tool_specs()
@@ -471,7 +483,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
headers.update(form_data.headers)
custom_headers = get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(url, headers=headers)
+7 -1
View File
@@ -125,7 +125,13 @@ async def process_uploaded_file(
if strict_match_mime_type(stt_supported_content_types, content_type):
file_path_processed = await asyncio.to_thread(Storage.get_file, file_path)
result = transcribe(request, file_path_processed, file_metadata, user)
result = await asyncio.to_thread(
transcribe,
request,
file_path_processed,
file_metadata,
user,
)
await process_file(
request,
+15 -16
View File
@@ -16,8 +16,6 @@ from open_webui.models.folders import (
Folders,
)
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR
@@ -32,6 +30,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.files import get_accessible_folder_files
log = logging.getLogger(__name__)
@@ -75,20 +74,10 @@ async def get_folders(
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
if folder.data:
if 'files' in folder.data:
valid_files = []
for file in folder.data['files']:
if file.get('type') == 'file':
if await Files.check_access_by_user_id(file.get('id'), user.id, 'read', db=db):
valid_files.append(file)
elif file.get('type') == 'collection':
if await Knowledges.check_access_by_user_id(file.get('id'), user.id, 'read', db=db):
valid_files.append(file)
else:
valid_files.append(file)
folder.data['files'] = valid_files
if folder.data and 'files' in folder.data:
accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db)
if len(accessible_files) != len(folder.data.get('files', [])):
folder.data['files'] = accessible_files
await Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
)
@@ -173,6 +162,16 @@ async def update_folder_name_by_id(
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
)
# Validate read access to every file/collection being attached.
# Folder files are consumed by chat middleware as RAG context.
if form_data.data and isinstance(form_data.data.get('files'), list):
accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db)
if len(accessible_files) != len(form_data.data['files']):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db)
return folder
+11 -3
View File
@@ -22,7 +22,7 @@ from open_webui.config import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.web.utils import validate_url
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.utils.session_pool import get_session
from open_webui.models.chats import Chats
@@ -442,6 +442,8 @@ GenerateImageForm = CreateImageForm # Alias for backward compatibility
async def get_image_data(data: str, headers=None):
try:
if data.startswith('http://') or data.startswith('https://'):
# Defense-in-depth: gate before fetch (mirrors load_url_image).
validate_url(data)
session = await get_session()
async with session.get(
data,
@@ -807,10 +809,16 @@ async def image_edits(
return data
if data.startswith('http://') or data.startswith('https://'):
# Validate URL to prevent SSRF attacks against local/private networks
# Validate URL to prevent SSRF attacks against local/private networks.
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
# called only on the originally-submitted URL; following 3xx redirects
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(data)
session = await get_session()
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
async with session.get(
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as r:
r.raise_for_status()
image_data = base64.b64encode(await r.read()).decode('utf-8')
+18
View File
@@ -31,6 +31,7 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.models.access_grants import AccessGrants
@@ -656,6 +657,14 @@ async def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
)
# KB write-access alone is not enough — caller must also be able to read the file.
if file.user_id != user.id and user.role != 'admin':
if not await has_access_to_file(file.id, 'read', user, db=db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Add content to the vector database
try:
await process_file(
@@ -1017,6 +1026,15 @@ async def add_files_to_knowledge_batch(
detail=f'File {missing_ids[0]} not found',
)
# Per-file read-access check — same gate as the single-file endpoint.
if user.role != 'admin':
for file in files:
if file.user_id != user.id and not await has_access_to_file(file.id, 'read', user, db=db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Process files
try:
result = await process_files_batch(
+128 -67
View File
@@ -4,6 +4,8 @@ import base64
import json
import asyncio
import logging
import posixpath
from urllib.parse import unquote
from open_webui.models.groups import Groups
from open_webui.models.models import (
@@ -26,15 +28,16 @@ from fastapi import (
Depends,
HTTPException,
Request,
status,
Response,
status,
)
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import RedirectResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING
from open_webui.internal.db import get_async_session
from sqlalchemy.ext.asyncio import AsyncSession
@@ -43,6 +46,34 @@ log = logging.getLogger(__name__)
router = APIRouter()
def _safe_static_redirect_path(url: str) -> Optional[str]:
"""
If url is a same-origin static asset path, return a normalized path safe for
RedirectResponse Location. Otherwise None (caller should fall back to default).
Rejects traversal (..), encoded dots, query/fragment, and non-/static targets.
"""
if not url or not isinstance(url, str):
return None
path = url.split('?', 1)[0].split('#', 1)[0].strip()
for _ in range(2):
decoded = unquote(path)
if decoded == path:
break
path = decoded
if '\x00' in path or '\\' in path:
return None
if not path.startswith('/'):
return None
normalized = posixpath.normpath(path)
if normalized in ('.', '/'):
return None
if not (normalized == '/static' or normalized.startswith('/static/')):
return None
if normalized == '/static':
return '/static/'
return normalized
def is_valid_model_id(model_id: str) -> bool:
return model_id and len(model_id) <= 256
@@ -108,18 +139,25 @@ async def get_models(
db=db,
)
return ModelAccessListResponse(
items=[
# Strip profile_image_url from meta — images are served via /model/profile/image.
items = []
for model in result.items:
data = model.model_dump()
if data.get('meta'):
data['meta'].pop('profile_image_url', None)
items.append(
ModelAccessResponse(
**model.model_dump(),
**data,
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or model.id in writable_model_ids
),
)
for model in result.items
],
)
return ModelAccessListResponse(
items=items,
total=result.total,
)
@@ -141,25 +179,12 @@ async def get_base_models(user=Depends(get_admin_user), db: AsyncSession = Depen
@router.get('/tags', response_model=list[str])
async def get_model_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
models = await Models.get_models(db=db)
else:
models = await Models.get_models_by_user_id(user.id, db=db)
tags_set = set()
for model in models:
if model.meta:
meta = model.meta.model_dump()
for tag in meta.get('tags', []):
try:
name = tag.get('name') if isinstance(tag, dict) else str(tag)
if name:
tags_set.add(name)
except Exception:
continue
tags = sorted(tags_set)
return tags
tags = await Models.get_all_tags(
user_id=user.id,
is_admin=(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL),
db=db,
)
return sorted(tags)
############################
@@ -388,30 +413,37 @@ class ModelIdForm(BaseModel):
async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
model = await Models.get_model_by_id(id, db=db)
if model:
if (
write_access = (
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or user.id == model.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='model',
resource_id=model.id,
permission='read',
permission='write',
db=db,
)
)
if write_access or await AccessGrants.has_access(
user_id=user.id,
resource_type='model',
resource_id=model.id,
permission='read',
db=db,
):
model_dict = model.model_dump()
# Strip params (system prompt and other admin-curated config)
# for read-only callers — matches the params strip already
# enforced on /api/models in utils/models.py. Owners, admins
# under BYPASS_ADMIN_ACCESS_CONTROL, and write-grant holders
# still receive the full object so the workspace edit UI keeps
# working for users who legitimately curate the model.
if not write_access:
model_dict['params'] = {}
return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='model',
resource_id=model.id,
permission='write',
db=db,
)
),
**model_dict,
write_access=write_access,
)
else:
raise HTTPException(
@@ -432,43 +464,72 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
@router.get('/model/profile/image')
async def get_model_profile_image(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
model = await Models.get_model_by_id(id, db=db)
profile_image_url = None
updated_at = None
if model:
etag = f'"{model.updated_at}"' if model.updated_at else None
# First, check the database for regular models
model_meta = await Models.get_model_meta_by_id(id, db=db)
if model_meta:
meta, updated_at = model_meta
profile_image_url = (meta or {}).get('profile_image_url')
if model.meta.profile_image_url:
if model.meta.profile_image_url.startswith('http'):
# Fallback: check arena models stored in config (not in the DB)
if not profile_image_url:
arena_models = getattr(
getattr(request.app.state, 'config', None),
'EVALUATION_ARENA_MODELS',
[],
)
for arena_model in arena_models:
if arena_model.get('id') == id:
profile_image_url = arena_model.get('meta', {}).get('profile_image_url')
break
if profile_image_url:
if profile_image_url.startswith('http'):
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
return Response(
status_code=status.HTTP_302_FOUND,
headers={'Location': model.meta.profile_image_url},
headers={'Location': profile_image_url},
)
elif model.meta.profile_image_url.startswith('data:image'):
try:
header, base64_data = model.meta.profile_image_url.split(',', 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(';')[0].lstrip('data:')
# When forwarding is disabled, fall through to the
# default image to prevent client-side IP/UA/Referer
# leaks via 302 redirect to external origins.
elif profile_image_url.startswith('data:image'):
try:
header, base64_data = profile_image_url.split(',', 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(';')[0].lstrip('data:')
headers = {'Content-Disposition': 'inline'}
if etag:
headers['ETag'] = etag
headers = {'Content-Disposition': 'inline'}
if updated_at:
headers['ETag'] = f'"{updated_at}"'
return StreamingResponse(
image_buffer,
media_type=media_type,
headers=headers,
)
except Exception as e:
pass
return StreamingResponse(
image_buffer,
media_type=media_type,
headers=headers,
)
except Exception:
pass
else:
safe_static = _safe_static_redirect_path(profile_image_url)
if safe_static:
return RedirectResponse(
url=safe_static,
status_code=status.HTTP_302_FOUND,
)
return FileResponse(f'{STATIC_DIR}/favicon.png')
else:
return FileResponse(f'{STATIC_DIR}/favicon.png')
return RedirectResponse(
url='/static/favicon.png',
status_code=status.HTTP_302_FOUND,
)
############################
+21 -3
View File
@@ -92,10 +92,13 @@ async def get_notes(
user_ids = list(set(note.user_id for note in notes))
users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)}
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
return [
NoteUserResponse(
**{
**note.model_dump(),
'is_pinned': note.id in pinned_note_ids,
'data': _truncate_note_data(note.data),
'user': UserResponse(**users[note.user_id].model_dump()),
}
@@ -135,6 +138,7 @@ async def get_pinned_notes(
NoteUserResponse(
**{
**note.model_dump(),
'is_pinned': True,
'data': _truncate_note_data(note.data),
'user': UserResponse(**users[note.user_id].model_dump()),
}
@@ -190,7 +194,9 @@ async def search_notes(
filter['user_id'] = user.id
result = await Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
for note in result.items:
note.is_pinned = note.id in pinned_note_ids
note.data = _truncate_note_data(note.data)
return result
@@ -287,7 +293,11 @@ async def get_note_by_id(
or has_public_write_access_grant(note.access_grants)
)
return NoteResponse(**note.model_dump(), write_access=write_access)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
return NoteResponse(
**{**note.model_dump(), 'is_pinned': note.id in pinned_note_ids},
write_access=write_access,
)
############################
@@ -338,6 +348,9 @@ async def update_note_by_id(
try:
note = await Notes.update_note_by_id(id, form_data, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
await sio.emit(
'note-events',
note.model_dump(),
@@ -401,7 +414,10 @@ async def update_note_access_by_id(
await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db)
return await Notes.get_note_by_id(id, db=db)
note = await Notes.get_note_by_id(id, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
return note
############################
@@ -440,7 +456,9 @@ async def pin_note_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
note = await Notes.toggle_note_pinned_by_id(id, db=db)
note = await Notes.toggle_note_pinned_by_id(id, user.id, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
return note
+14 -3
View File
@@ -86,6 +86,17 @@ log = logging.getLogger(__name__)
#
##########################################
# Headers that become stale after aiohttp auto-decompresses the upstream
# response body. Forwarding them verbatim causes desktop / programmatic
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
def _clean_proxy_headers(raw_headers) -> dict:
"""Return a copy of *raw_headers* with stale encoding headers removed."""
return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS}
async def send_get_request(url, key=None, user: UserModel = None):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
@@ -163,7 +174,7 @@ async def send_request(
r.raise_for_status()
if stream:
response_headers = dict(r.headers)
response_headers = _clean_proxy_headers(r.headers)
if content_type:
response_headers['Content-Type'] = content_type
@@ -1116,7 +1127,7 @@ async def generate_chat_completion(
payload = apply_model_params_to_body_ollama(params, payload)
if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
await check_model_access(user, model_info, bypass_filter)
else:
@@ -1271,7 +1282,7 @@ async def generate_openai_chat_completion(
system = params.pop('system', None)
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
await check_model_access(user, model_info)
else:
+60 -7
View File
@@ -62,7 +62,7 @@ from open_webui.utils.session_pool import (
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.headers import include_user_info_headers, get_custom_headers
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
log = logging.getLogger(__name__)
@@ -76,6 +76,17 @@ log = logging.getLogger(__name__)
#
##########################################
# Headers that become stale after aiohttp auto-decompresses the upstream
# response body. Forwarding them verbatim causes desktop / programmatic
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
def _clean_proxy_headers(raw_headers) -> dict:
"""Return a copy of *raw_headers* with stale encoding headers removed."""
return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS}
async def send_get_request(
request: Request = None,
@@ -204,7 +215,8 @@ async def get_headers_and_cookies(
headers['Authorization'] = f'Bearer {token}'
if config.get('headers') and isinstance(config.get('headers'), dict):
headers = {**headers, **config.get('headers')}
custom_headers = get_custom_headers(config.get('headers'), user, metadata)
headers.update(custom_headers)
return headers, cookies
@@ -428,6 +440,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
connection_type = api_config.get('connection_type', 'external')
prefix_id = api_config.get('prefix_id', None)
tags = api_config.get('tags', [])
provider = api_config.get('provider', '')
model_list = response if isinstance(response, list) else response.get('data', [])
if not isinstance(model_list, list):
@@ -448,6 +461,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
if connection_type:
model['connection_type'] = connection_type
if provider:
model['provider'] = provider
log.debug(f'get_all_models:responses() {responses}')
return responses
@@ -477,6 +493,39 @@ async def get_filtered_models(models, user, db=None):
return filtered_models
async def get_openai_loaded_models(request: Request, models: dict, api_base_urls: list):
"""
Fetch loaded-model state from providers that expose it and annotate
each model dict with a ``loaded`` boolean.
Currently supports:
- **llama.cpp** – queries ``GET /slots`` and matches slot model IDs.
"""
api_configs = request.app.state.config.OPENAI_API_CONFIGS
api_keys = request.app.state.config.OPENAI_API_KEYS
for idx, url in enumerate(api_base_urls):
api_config = api_configs.get(
str(idx),
api_configs.get(url, {}),
)
provider = api_config.get('provider', '')
if provider == 'llama.cpp':
try:
root_url = url.rstrip('/').removesuffix('/v1')
key = api_keys[idx] if idx < len(api_keys) else None
slots = await send_get_request(url=f'{root_url}/slots', key=key)
loaded_model_ids = (
{s.get('model') for s in slots if s.get('model')} if isinstance(slots, list) else set()
)
for model_id, model in models.items():
if model.get('urlIdx') == idx:
model['loaded'] = model_id in loaded_model_ids
except Exception as e:
log.debug(f'Failed to fetch llama.cpp slots for idx {idx}: {e}')
@cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models',
@@ -537,6 +586,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
'owned_by': 'openai',
'openai': model,
'connection_type': model.get('connection_type', 'external'),
'provider': model.get('provider', ''),
'urlIdx': idx,
}
@@ -545,6 +595,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
models = get_merged_models(map(extract_data, responses))
log.debug(f'models: {models}')
# Fetch loaded state for providers that support it (e.g. llama.cpp /slots)
await get_openai_loaded_models(request, models, api_base_urls)
request.app.state.OPENAI_MODELS = models
return {'data': list(models.values())}
@@ -1066,7 +1119,7 @@ async def generate_chat_completion(
payload = apply_model_params_to_body_openai(params, payload)
if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
await check_model_access(user, model_info, bypass_filter)
else:
@@ -1219,7 +1272,7 @@ async def generate_chat_completion(
return StreamingResponse(
stream_wrapper(r, content_handler=stream_chunks_handler),
status_code=r.status,
headers=dict(r.headers),
headers=_clean_proxy_headers(r.headers),
)
else:
try:
@@ -1304,7 +1357,7 @@ async def embeddings(request: Request, form_data: dict, user):
return StreamingResponse(
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
headers=_clean_proxy_headers(r.headers),
)
else:
try:
@@ -1425,7 +1478,7 @@ async def responses(
return StreamingResponse(
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
headers=_clean_proxy_headers(r.headers),
)
else:
try:
@@ -1542,7 +1595,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
return StreamingResponse(
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
headers=_clean_proxy_headers(r.headers),
)
else:
try:
+1 -7
View File
@@ -61,13 +61,7 @@ async def get_prompts(user=Depends(get_verified_user), db: AsyncSession = Depend
async def get_prompt_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
return await Prompts.get_tags(db=db)
else:
prompts = await Prompts.get_prompts_by_user_id(user.id, 'read', db=db)
tags = set()
for prompt in prompts:
if prompt.tags:
tags.update(prompt.tags)
return sorted(list(tags))
return await Prompts.get_tags_by_user_id(user.id, db=db)
@router.get('/list', response_model=PromptAccessListResponse)
+39 -17
View File
@@ -57,6 +57,7 @@ from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.web.ollama import search_ollama_cloud
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.brave import search_brave
from open_webui.retrieval.web.brave_llm_context import search_brave_llm_context
from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.mojeek import search_mojeek
from open_webui.retrieval.web.bocha import search_bocha
@@ -259,22 +260,6 @@ class SearchForm(BaseModel):
queries: List[str]
@router.get('/')
async def get_status(request: Request):
return {
'status': True,
'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE,
'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP,
'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE,
'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE,
'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL,
'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL,
'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING,
'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
}
@router.get('/embedding')
async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
return {
@@ -350,7 +335,7 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd
unload_embedding_model(request)
try:
request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL.strip()
request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE
request.app.state.config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING
request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
@@ -480,6 +465,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL,
'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY,
'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL,
'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN,
# MinerU settings
'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE,
'MINERU_API_URL': request.app.state.config.MINERU_API_URL,
@@ -529,6 +516,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY,
'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID,
'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY,
'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY,
'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY,
'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY,
@@ -597,6 +585,7 @@ class WebConfig(BaseModel):
GOOGLE_PSE_API_KEY: Optional[str] = None
GOOGLE_PSE_ENGINE_ID: Optional[str] = None
BRAVE_SEARCH_API_KEY: Optional[str] = None
BRAVE_SEARCH_CONTEXT_TOKENS: Optional[int] = None
KAGI_SEARCH_API_KEY: Optional[str] = None
MOJEEK_SEARCH_API_KEY: Optional[str] = None
BOCHA_SEARCH_API_KEY: Optional[str] = None
@@ -686,6 +675,8 @@ class ConfigForm(BaseModel):
DOCUMENT_INTELLIGENCE_MODEL: Optional[str] = None
MISTRAL_OCR_API_BASE_URL: Optional[str] = None
MISTRAL_OCR_API_KEY: Optional[str] = None
PADDLEOCR_VL_BASE_URL: Optional[str] = None
PADDLEOCR_VL_TOKEN: Optional[str] = None
# MinerU settings
MINERU_API_MODE: Optional[str] = None
@@ -887,6 +878,16 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
if form_data.MISTRAL_OCR_API_KEY is not None
else request.app.state.config.MISTRAL_OCR_API_KEY
)
request.app.state.config.PADDLEOCR_VL_BASE_URL = (
form_data.PADDLEOCR_VL_BASE_URL
if form_data.PADDLEOCR_VL_BASE_URL is not None
else request.app.state.config.PADDLEOCR_VL_BASE_URL
)
request.app.state.config.PADDLEOCR_VL_TOKEN = (
form_data.PADDLEOCR_VL_TOKEN
if form_data.PADDLEOCR_VL_TOKEN is not None
else request.app.state.config.PADDLEOCR_VL_TOKEN
)
# MinerU settings
request.app.state.config.MINERU_API_MODE = (
@@ -1067,6 +1068,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY
request.app.state.config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID
request.app.state.config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY
if form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS is not None:
request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS
request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY
request.app.state.config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY
request.app.state.config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY
@@ -1152,6 +1155,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL,
'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY,
'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL,
'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN,
# MinerU settings
'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE,
'MINERU_API_URL': request.app.state.config.MINERU_API_URL,
@@ -1200,6 +1205,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY,
'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID,
'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY,
'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY,
'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY,
'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY,
@@ -1561,6 +1567,8 @@ async def process_file(
if collection_name is None:
collection_name = f'file-{file.id}'
else:
await _validate_collection_access([collection_name], user, access_type='write')
if form_data.content:
# Update the content in the file
@@ -1947,6 +1955,17 @@ def search_web(request: Request, engine: str, query: str, user=None) -> list[Sea
)
else:
raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables')
elif engine == 'brave_llm_context':
if request.app.state.config.BRAVE_SEARCH_API_KEY:
return search_brave_llm_context(
request.app.state.config.BRAVE_SEARCH_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
)
else:
raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables')
elif engine == 'kagi':
if request.app.state.config.KAGI_SEARCH_API_KEY:
return search_kagi(
@@ -2600,6 +2619,9 @@ async def process_files_batch(
collection_name = form_data.collection_name
if collection_name:
await _validate_collection_access([collection_name], user, access_type='write')
file_results: List[BatchProcessFilesResult] = []
file_errors: List[BatchProcessFilesResult] = []
file_updates: List[FileUpdateForm] = []
+26
View File
@@ -176,6 +176,19 @@ async def create_new_skill(
detail=ERROR_MESSAGES.ID_TAKEN,
)
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/calendar pattern). Without this, a user with
# workspace.skills permission could attach principal_id='*' read/write
# grants in the create payload, bypassing the sharing.public_skills gate
# that the dedicated /access/update endpoint already enforces.
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
)
try:
skill = await Skills.insert_new_skill(user.id, form_data, db=db)
if skill:
@@ -276,6 +289,19 @@ async def update_skill_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/calendar pattern). The access check above only
# restricts WHO can write to the skill; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their own skill publicly
# readable/writable without sharing.public_skills permission.
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
)
try:
updated = {
**form_data.model_dump(exclude={'id'}),
+19 -7
View File
@@ -79,6 +79,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@@ -99,6 +100,7 @@ class TaskConfigForm(BaseModel):
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
QUERY_GENERATION_PROMPT_TEMPLATE: str
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
ENABLE_VOICE_MODE_PROMPT: bool
VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
@@ -127,6 +129,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D
request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE
request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE
return {
@@ -145,6 +148,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@@ -159,6 +163,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -187,7 +192,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
else:
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
content = title_generation_template(template, form_data['messages'], user)
content = await title_generation_template(template, form_data['messages'], user)
max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000)
@@ -236,6 +241,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -264,7 +270,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
else:
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
content = follow_up_generation_template(template, form_data['messages'], user)
content = await follow_up_generation_template(template, form_data['messages'], user)
payload = {
'model': task_model_id,
@@ -304,6 +310,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -332,7 +339,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
else:
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
content = tags_generation_template(template, form_data['messages'], user)
content = await tags_generation_template(template, form_data['messages'], user)
payload = {
'model': task_model_id,
@@ -366,6 +373,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
async def generate_image_prompt(request: Request, form_data: dict, user=Depends(get_verified_user)):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -394,7 +402,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
content = image_prompt_generation_template(template, form_data['messages'], user)
content = await image_prompt_generation_template(template, form_data['messages'], user)
payload = {
'model': task_model_id,
@@ -446,6 +454,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -474,7 +483,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
else:
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
content = query_generation_template(template, form_data['messages'], user)
content = await query_generation_template(template, form_data['messages'], user)
payload = {
'model': task_model_id,
@@ -524,6 +533,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -552,7 +562,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
else:
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
content = autocomplete_generation_template(template, prompt, messages, type, user)
content = await autocomplete_generation_template(template, prompt, messages, type, user)
payload = {
'model': task_model_id,
@@ -586,6 +596,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
async def generate_emoji(request: Request, form_data: dict, user=Depends(get_verified_user)):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
@@ -611,7 +622,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE
content = emoji_generation_template(template, form_data['prompt'], user)
content = await emoji_generation_template(template, form_data['prompt'], user)
payload = {
'model': task_model_id,
@@ -651,6 +662,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
async def generate_moa_response(request: Request, form_data: dict, user=Depends(get_verified_user)):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
+3
View File
@@ -17,6 +17,7 @@ from starlette.background import BackgroundTask
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_connection_access
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.config import TERMINAL_PROXY_HEADERS
from open_webui.models.groups import Groups
from open_webui.models.users import Users
@@ -151,6 +152,8 @@ async def proxy_terminal(
for key, value in upstream_response.headers.items()
if key.lower() not in STRIPPED_RESPONSE_HEADERS
}
if TERMINAL_PROXY_HEADERS:
filtered_headers.update(TERMINAL_PROXY_HEADERS)
# Stream binary responses directly
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
+11
View File
@@ -480,6 +480,17 @@ async def update_tools_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Content edits trigger exec on load — gate them behind workspace.tools (matches /create).
if form_data.content != tools.content:
if user.role != 'admin' and not (
await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db)
or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
try:
form_data.content = replace_imports(form_data.content)
tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content)
+24 -8
View File
@@ -29,7 +29,7 @@ from open_webui.models.users import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import STATIC_DIR
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
from open_webui.internal.db import get_async_session
@@ -193,6 +193,8 @@ class SharingPermissions(BaseModel):
public_skills: bool = False
notes: bool = False
public_notes: bool = True
public_chats: bool = False
public_calendars: bool = False
class AccessGrantsPermissions(BaseModel):
@@ -234,6 +236,7 @@ class FeaturesPermissions(BaseModel):
code_interpreter: bool = True
memories: bool = True
automations: bool = False
calendar: bool = True
class SettingsPermissions(BaseModel):
@@ -477,23 +480,32 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
user = await Users.get_user_by_id(user_id)
if user:
if user.profile_image_url:
# check if it's url or base64
if user.profile_image_url.startswith('http'):
return Response(
status_code=status.HTTP_302_FOUND,
headers={'Location': user.profile_image_url},
)
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
return Response(
status_code=status.HTTP_302_FOUND,
headers={'Location': user.profile_image_url},
)
# When forwarding is disabled, fall through to the
# default image to prevent client-side IP/UA/Referer
# leaks via 302 redirect to external origins.
elif user.profile_image_url.startswith('data:image'):
try:
header, base64_data = user.profile_image_url.split(',', 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(';')[0].lstrip('data:')
media_type = header.split(';')[0].lstrip('data:').lower()
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
return FileResponse(f'{STATIC_DIR}/user.png')
return StreamingResponse(
image_buffer,
media_type=media_type,
headers={'Content-Disposition': 'inline'},
headers={
'Content-Disposition': 'inline',
'X-Content-Type-Options': 'nosniff',
},
)
except Exception as e:
pass
@@ -550,6 +562,8 @@ async def update_user_by_id(
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except HTTPException:
raise
except Exception as e:
log.error(f'Error checking primary admin status: {e}')
raise HTTPException(
@@ -631,6 +645,8 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Asyn
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except HTTPException:
raise
except Exception as e:
log.error(f'Error checking primary admin status: {e}')
raise HTTPException(
+98 -17
View File
@@ -832,7 +832,76 @@ async def disconnect(sid):
# print(f"Unknown session ID {sid} disconnected")
async def _make_channel_emitter(request_info):
"""Event emitter that routes pipeline output to a channel message.
Translates chat:completion events into channel message:update socket
emissions, throttled to avoid flooding with per-token updates.
"""
channel_id = request_info['chat_id'].removeprefix('channel:')
message_id = request_info['message_id']
state = {'last_emit_at': 0.0}
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
async def _emit_channel_update(content: str, done: bool = False):
from open_webui.models.messages import Messages, MessageForm
update_form = MessageForm(content=content)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
msg = await Messages.get_message_by_id(message_id)
existing_meta = (msg.meta or {}) if msg else {}
update_form = MessageForm(
content=content,
meta={**existing_meta, 'done': True},
)
await Messages.update_message_by_id(message_id, update_form)
message = await Messages.get_message_by_id(message_id)
if message:
await sio.emit(
'events:channel',
{
'channel_id': channel_id,
'message_id': message_id,
'data': {
'type': 'message:update',
'data': message.model_dump(),
},
},
to=f'channel:{channel_id}',
)
async def __channel_emitter__(event_data):
event_type = event_data.get('type')
if event_type == 'chat:completion':
data = event_data.get('data', {})
content = data.get('content', '')
done = data.get('done', False)
if not content and not done:
return
now = __import__('time').time()
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
state['last_emit_at'] = now
await _emit_channel_update(content, done)
elif event_type == 'chat:message:error':
error = event_data.get('data', {}).get('error', {})
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)
await _emit_channel_update(f'Error: {error_content}', done=True)
return __channel_emitter__
async def get_event_emitter(request_info, update_db=True):
# Channel mode: route pipeline output to channel message updates
if request_info.get('chat_id', '').startswith('channel:'):
return await _make_channel_emitter(request_info)
async def __event_emitter__(event_data):
user_id = request_info['user_id']
chat_id = request_info['chat_id']
@@ -888,13 +957,15 @@ async def get_event_emitter(request_info, update_db=True):
)
elif event_type == 'embeds':
message = await Chats.get_message_by_id_and_message_id(
request_info['chat_id'],
request_info['message_id'],
)
event_payload = event_data.get('data', {})
embeds = event_payload.get('embeds', [])
embeds = event_data.get('data', {}).get('embeds', [])
embeds.extend(message.get('embeds', []))
if not event_payload.get('replace', False):
message = await Chats.get_message_by_id_and_message_id(
request_info['chat_id'],
request_info['message_id'],
)
embeds.extend(message.get('embeds', []))
await Chats.upsert_message_to_chat_by_id_and_message_id(
request_info['chat_id'],
@@ -948,17 +1019,27 @@ async def get_event_emitter(request_info, update_db=True):
async def get_event_call(request_info):
async def __event_caller__(event_data):
response = await sio.call(
'events',
{
'chat_id': request_info.get('chat_id', None),
'message_id': request_info.get('message_id', None),
'data': event_data,
},
to=request_info['session_id'],
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
)
return response
session_id = request_info['session_id']
# Fast-fail if the client has disconnected.
if session_id not in SESSION_POOL:
log.warning(f'Event caller: session {session_id} no longer connected')
return {'error': 'Client session disconnected.'}
try:
return await sio.call(
'events',
{
'chat_id': request_info.get('chat_id', None),
'message_id': request_info.get('message_id', None),
'data': event_data,
},
to=session_id,
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
)
except TimeoutError:
log.warning(f'Event caller timed out for session {session_id}')
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
return __event_caller__
+74 -21
View File
@@ -238,9 +238,13 @@ async def fetch_url(
content, _ = await asyncio.to_thread(get_content_from_url, __request__, url)
# Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH)
max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None)
if max_length and max_length > 0 and len(content) > max_length:
content = content[:max_length] + '\n\n[Content truncated...]'
# Guard: content may be None if the web loader silently failed
if content is not None:
max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None)
if max_length and max_length > 0 and len(content) > max_length:
content = content[:max_length] + '\n\n[Content truncated...]'
else:
content = ''
return content
except Exception as e:
@@ -467,9 +471,15 @@ async def execute_code(
# Parse the output - pyodide returns dict with stdout, stderr, result
if isinstance(output, dict):
stdout = output.get('stdout', '')
stderr = output.get('stderr', '')
result = output.get('result', '')
# Handle error responses from event_caller (e.g. session disconnected, timeout)
if output.get('error') and not output.get('stdout') and not output.get('result'):
stderr = output['error']
stdout = ''
result = ''
else:
stdout = output.get('stdout', '')
stderr = output.get('stderr', '')
result = output.get('result', '')
else:
stdout = ''
stderr = ''
@@ -2568,8 +2578,11 @@ async def create_automation(
if not user:
return json.dumps({'error': 'User not found'})
# Always use the calling model for the automation
model_id = (__metadata__ or {}).get('model_id')
# Fall back to model dict ID since __metadata__ may predate model_id assignment
metadata = __metadata__ or {}
model_id = metadata.get('model_id') or (
metadata.get('model', {}).get('id') if isinstance(metadata.get('model'), dict) else None
)
if not model_id:
return json.dumps({'error': 'Could not detect current model'})
@@ -2671,7 +2684,7 @@ async def update_automation(
is_active=automation.is_active,
)
updated = await Automations.update(automation_id, form, next_run_ns(new_rrule, tz=tz))
updated = await Automations.update_by_id(automation_id, form, next_run_ns(new_rrule, tz=tz))
return json.dumps(
{
@@ -2888,6 +2901,9 @@ def _ns_to_dt(ns: int, tz) -> str:
def _event_to_dict(event, tz) -> dict:
"""Convert a calendar event model to a human-friendly dict with local timestamps."""
alert_minutes = None
if event.meta and 'alert_minutes' in event.meta:
alert_minutes = event.meta['alert_minutes']
return {
'id': event.id,
'calendar_id': event.calendar_id,
@@ -2897,6 +2913,7 @@ def _event_to_dict(event, tz) -> dict:
'end': _ns_to_dt(event.end_at, tz) if event.end_at else None,
'all_day': event.all_day,
'location': event.location or '',
'reminder_minutes': alert_minutes if alert_minutes is not None else 10,
'color': event.color,
'is_cancelled': event.is_cancelled,
}
@@ -2911,8 +2928,9 @@ async def search_calendar_events(
__user__: dict = None,
) -> str:
"""
Search calendar events by text and/or date range.
Returns matching events across all accessible calendars.
Search calendar events, reminders, and scheduled items by text and/or date range.
Use this to check what's coming up, find a specific event or reminder, or list
the user's schedule for a time period.
:param query: Search text to match against event title, description, or location (optional)
:param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional)
@@ -3003,20 +3021,24 @@ async def create_calendar_event(
calendar_id: Optional[str] = None,
all_day: bool = False,
location: Optional[str] = None,
reminder_minutes: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Create a new calendar event. If no calendar_id is provided, the event is
added to the user's default calendar.
Create a calendar event, reminder, or alarm. Use this when the user wants to
schedule an event, set a reminder, create an alarm, or says things like
"remind me", "don't let me forget", "notify me at", or "add to my calendar".
For simple reminders, omit end/location/all_day and set reminder_minutes to 0.
:param title: Event title
:param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00")
:param end: End datetime string in your local time (optional, omit for point-in-time events)
:param description: Event description (optional)
:param title: Event or reminder title (e.g. "Team standup", "Take medicine", "Call mom")
:param start: Start datetime in the user's local time (e.g. "2026-04-20 09:00")
:param end: End datetime in the user's local time (optional — omit for reminders or point-in-time events)
:param description: Event description or notes (optional)
:param calendar_id: Target calendar ID (optional, uses default calendar if omitted)
:param all_day: Whether this is an all-day event (default: false)
:param location: Event location (optional)
:param reminder_minutes: Minutes before the event to send a notification (optional, default: 10). Use 0 for "at time of event", -1 for no notification.
:return: JSON with the created event details including id
"""
if __request__ is None:
@@ -3079,6 +3101,18 @@ async def create_calendar_event(
# Default to 1 hour duration
end_ns = start_ns + 3_600_000_000_000
# Build meta with reminder setting
meta = {}
if reminder_minutes is not None:
if isinstance(reminder_minutes, str):
try:
reminder_minutes = int(reminder_minutes)
except ValueError:
reminder_minutes = 10
meta['alert_minutes'] = reminder_minutes
else:
meta['alert_minutes'] = 10
form = CalendarEventForm(
calendar_id=calendar_id,
title=title,
@@ -3087,6 +3121,7 @@ async def create_calendar_event(
end_at=end_ns,
all_day=all_day,
location=location,
meta=meta,
)
event = await CalendarEvents.insert_new_event(user_id, form)
@@ -3114,6 +3149,7 @@ async def update_calendar_event(
all_day: Optional[bool] = None,
location: Optional[str] = None,
is_cancelled: Optional[bool] = None,
reminder_minutes: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
@@ -3129,6 +3165,7 @@ async def update_calendar_event(
:param all_day: Whether this is an all-day event (optional)
:param location: New event location (optional)
:param is_cancelled: Set to true to cancel the event (optional)
:param reminder_minutes: Minutes before the event to send a reminder notification (optional). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before).
:return: JSON with the updated event details
"""
if __request__ is None:
@@ -3149,8 +3186,10 @@ async def update_calendar_event(
return json.dumps({'error': 'Event not found'})
# Check write access to the event's calendar
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
if event.user_id != user_id and __user__.get('role') != 'admin':
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if not cal:
return json.dumps({'error': 'Access denied'})
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
@@ -3183,6 +3222,17 @@ async def update_calendar_event(
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}'})
# Build meta update with reminder setting if provided
meta = None
if reminder_minutes is not None:
if isinstance(reminder_minutes, str):
try:
reminder_minutes = int(reminder_minutes)
except ValueError:
reminder_minutes = None
if reminder_minutes is not None:
meta = {'alert_minutes': reminder_minutes}
form = CalendarEventUpdateForm(
title=title,
description=description,
@@ -3191,6 +3241,7 @@ async def update_calendar_event(
all_day=all_day,
location=location,
is_cancelled=is_cancelled,
meta=meta,
)
updated = await CalendarEvents.update_event_by_id(event_id, form)
@@ -3238,8 +3289,10 @@ async def delete_calendar_event(
return json.dumps({'error': 'Event not found'})
# Check write access
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
if event.user_id != user_id and __user__.get('role') != 'admin':
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if not cal:
return json.dumps({'error': 'Access denied'})
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
@@ -87,3 +87,38 @@ async def has_access_to_file(
return True
return False
async def get_accessible_folder_files(
entries: list[dict] | None,
user: UserModel,
db: AsyncSession | None = None,
) -> list[dict]:
"""Filter folder.data['files'] entries to those the caller can read.
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
Admins bypass all checks. Unknown types are kept as-is.
"""
if not entries:
return []
if user.role == 'admin':
return list(entries)
accessible: list[dict] = []
for entry in entries:
if not isinstance(entry, dict):
continue
entry_type = entry.get('type')
entry_id = entry.get('id')
if not entry_id:
accessible.append(entry)
continue
if entry_type == 'file':
if await has_access_to_file(entry_id, 'read', user, db=db):
accessible.append(entry)
elif entry_type == 'collection':
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
accessible.append(entry)
else:
accessible.append(entry)
return accessible
+17 -2
View File
@@ -41,6 +41,7 @@ from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from open_webui.env import CUSTOM_API_KEY_HEADER
from open_webui.internal.db import ScopedSession
from open_webui.utils.auth import get_http_authorization_cred
@@ -87,6 +88,13 @@ class CommitSessionMiddleware:
await self.app(scope, receive, send)
return
path = scope.get('path', '')
# Keep health probes independent from sync session commit/remove
# so DB pressure cannot delay or fail probe responses.
if path in {'/health', '/ready', '/health/db'}:
await self.app(scope, receive, send)
return
try:
await self.app(scope, receive, send)
except BaseException:
@@ -119,9 +127,16 @@ class CommitSessionMiddleware:
class AuthTokenMiddleware:
"""Extract the bearer/cookie/x-api-key credential and stash it on
"""Extract the bearer/cookie/API-key credential and stash it on
`request.state.token`.
The header used for API-key transport is controlled by the
``CUSTOM_API_KEY_HEADER`` environment variable (default ``x-api-key``).
This is useful when Open WebUI sits behind a reverse proxy that
consumes the ``Authorization`` header for its own authentication —
set the env var to a unique header (e.g. ``X-OpenWebUI-Key``) so
the middleware checks that instead and avoids the 401 short-circuit.
Routes that depend on `get_verified_user` etc. read this state.
Also exposes `request.state.enable_api_keys` (snapshotted at request
entry from runtime config) and stamps an `X-Process-Time` response
@@ -146,7 +161,7 @@ class AuthTokenMiddleware:
if cookie_token:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token)
if token is None:
api_key = request.headers.get('x-api-key')
api_key = request.headers.get(CUSTOM_API_KEY_HEADER)
if api_key:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
+1 -1
View File
@@ -360,7 +360,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
await _record_run(automation.id, 'error', error='User not found')
return
prompt = prompt_template(automation.data['prompt'], user)
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
terminal_config = automation.data.get('terminal')
+12 -2
View File
@@ -73,6 +73,11 @@ async def generate_direct_chat_completion(
request_id = str(uuid.uuid4()) # Generate a unique request ID
event_caller = await get_event_call(metadata)
if event_caller is None:
raise Exception(
'Direct connection requires an active WebSocket session; '
'cannot generate completion in this context (e.g. background task).'
)
channel = f'{user_id}:{session_id}:{request_id}'
logging.info(f'WebSocket channel: {channel}')
@@ -180,10 +185,14 @@ async def generate_chat_completion(
}
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
# Merge the direct connection model into server models so that
# task functions (title, tags, etc.) can resolve a server-side
# task model while still having the direct model available.
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
log.debug(f'direct connection to model: {models}')
log.debug(f'direct connection to model: {request.state.model["id"]}')
else:
models = request.app.state.MODELS
@@ -193,7 +202,7 @@ async def generate_chat_completion(
model = models[model_id]
if getattr(request.state, 'direct', False):
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
else:
# Check if user has access to the model
@@ -310,6 +319,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
**request.app.state.MODELS,
request.state.model['id']: request.state.model,
}
else:
+3 -1
View File
@@ -8,6 +8,8 @@ import aiohttp
import websockets
from pydantic import BaseModel
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS
logger = logging.getLogger(__name__)
@@ -88,7 +90,7 @@ class JupyterCodeExecuter:
async with self.session.post(
'login',
data={'_xsrf': xsrf_token, 'password': self.password},
allow_redirects=False,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as response:
response.raise_for_status()
self.session.cookie_jar.update_cookies(response.cookies)
+13 -3
View File
@@ -26,7 +26,11 @@ import base64
import io
import re
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK
from open_webui.env import (
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK,
)
from open_webui.utils.session_pool import get_session
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
@@ -53,11 +57,17 @@ _IMAGE_MIME_FALLBACK = {
async def get_image_base64_from_url(url: str) -> Optional[str]:
try:
if url.startswith('http'):
# Validate URL to prevent SSRF attacks against local/private networks
# Validate URL to prevent SSRF attacks against local/private networks.
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
# called only on the originally-submitted URL; following 3xx redirects
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(url)
# Download the image from the URL
session = await get_session()
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response:
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
image_data = await response.read()
encoded_string = base64.b64encode(image_data).decode('utf-8')
+1 -1
View File
@@ -14,7 +14,7 @@ async def get_function_module(request, function_id, load_from_db=True):
"""
Get the function module by its ID.
"""
function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db)
function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db=load_from_db)
return function_module
+23
View File
@@ -16,3 +16,26 @@ def include_user_info_headers(headers, user):
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email,
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
}
def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -> dict:
if not custom_headers or not isinstance(custom_headers, dict):
return {}
metadata = metadata or {}
template_vars = {
'{{CHAT_ID}}': metadata.get('chat_id', '') or '',
'{{MESSAGE_ID}}': metadata.get('message_id', '') or '',
'{{USER_ID}}': (user.id if user else '') or '',
'{{USER_NAME}}': (user.name if user else '') or '',
}
parsed_headers = {}
for key, value in custom_headers.items():
if not isinstance(value, str):
value = str(value)
for token, val in template_vars.items():
value = value.replace(token, val)
parsed_headers[key] = value
return parsed_headers
+11 -13
View File
@@ -44,7 +44,7 @@ def create_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True)
async def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
@@ -155,20 +155,18 @@ class MCPClient:
self.session = None
try:
await asyncio.wait_for(
asyncio.shield(exit_stack.aclose()),
timeout=5.0,
)
except asyncio.TimeoutError:
# IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for()
# because they create a new asyncio task, which violates the MCP SDK's
# requirement that its TaskGroup be exited in the exact same task.
# ALSO do NOT use anyio.CancelScope(shield=True) or anyio.fail_after(),
# because they push a new cancel scope onto the task, violating LIFO
# order when aclose() attempts to exit the inner TaskGroup.
# We simply call aclose() directly. If the task is cancelled, the
# sockets will eventually be cleaned up by garbage collection.
await exit_stack.aclose()
except TimeoutError:
log.warning('MCPClient.disconnect() timed out after 5 s')
except RuntimeError as exc:
# The MCP SDK's streamable_http transport uses anyio task
# groups and async generators internally. When we close
# a session that was interrupted mid-flight these can
# raise RuntimeError ("aclose(): asynchronous generator is
# already running" or "Attempted to exit cancel scope in a
# different task"). Swallowing the error here prevents the
# orphaned coroutines from spinning at 100 % CPU.
log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc)
except Exception as exc:
log.debug('MCPClient.disconnect() error: %s', exc)
+241 -94
View File
@@ -106,6 +106,7 @@ from open_webui.utils.tools import (
get_terminal_tools,
)
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.access_control.files import get_accessible_folder_files
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.filter import (
get_sorted_filter_ids,
@@ -969,7 +970,7 @@ def get_source_context(sources: list, source_ids: dict = None, include_content:
return context_string
def apply_source_context_to_messages(
async def apply_source_context_to_messages(
request: Request,
messages: list,
sources: list,
@@ -995,13 +996,13 @@ def apply_source_context_to_messages(
if RAG_SYSTEM_CONTEXT:
return add_or_update_system_message(
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
messages,
append=True,
)
else:
return add_or_update_user_message(
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
messages,
append=False,
)
@@ -1164,6 +1165,15 @@ async def process_tool_result(
'url': file_url,
}
)
elif item.get('type') == 'resource':
resource = item.get('resource', {})
text = resource.get('text', '')
if isinstance(text, str) and text:
try:
text = json.loads(text)
except json.JSONDecodeError:
pass
tool_response.append(text)
tool_result = tool_response[0] if len(tool_response) == 1 else tool_response
else: # OpenAPI
for item in tool_result:
@@ -1546,11 +1556,11 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
except Exception as e:
log.exception(e)
queries = [user_message]
queries = [user_message or '']
# Check if generated queries are empty
if len(queries) == 1 and queries[0].strip() == '':
queries = [user_message]
queries = [user_message or '']
# Check if queries are not found
if len(queries) == 0:
@@ -1698,7 +1708,7 @@ async def add_file_context(messages: list, chat_id: str, user) -> list:
"""
Add file URLs to messages for native function calling.
"""
if not chat_id or chat_id.startswith('local:'):
if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'):
return messages
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id)
@@ -1754,7 +1764,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
if not chat_id or not isinstance(chat_id, str) or not __event_emitter__:
return form_data
if chat_id.startswith('local:'):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
message_list = form_data.get('messages', [])
else:
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id)
@@ -1991,7 +2001,7 @@ async def chat_completion_files_handler(
)
if len(queries) == 0:
queries = [get_last_user_message(body['messages'])]
queries = [get_last_user_message(body['messages']) or '']
try:
# Directly await async get_sources_from_items (no thread needed - fully async now)
@@ -2156,7 +2166,27 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[
return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages]
def process_messages_with_output(messages: list[dict]) -> list[dict]:
def get_reasoning_format(model: dict) -> str | None:
"""
Determine how reasoning should be included in reconstructed messages.
Returns:
'think_tags': Ollama expects <think> tags in content.
'reasoning_content': llama.cpp supports reasoning_content as a top-level field.
None: skip reasoning (safe default for strict providers).
"""
provider = model.get('provider', '')
if provider == 'ollama':
return 'think_tags'
if provider == 'llama.cpp':
return 'reasoning_content'
return None
def process_messages_with_output(
messages: list[dict],
reasoning_format: str | None = None,
) -> list[dict]:
"""
Process messages with OR-aligned output items for LLM consumption.
@@ -2168,7 +2198,11 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
for message in messages:
if message.get('role') == 'assistant' and message.get('output'):
# Use output items for clean OpenAI-format messages
output_messages = convert_output_to_messages(message['output'], raw=True)
output_messages = convert_output_to_messages(
message['output'],
raw=True,
reasoning_format=reasoning_format,
)
if output_messages:
processed.extend(output_messages)
continue
@@ -2180,6 +2214,43 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
return processed
SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)\|?[^>]*>')
def _get_text_parts(message: dict) -> list[str]:
"""Return all text segments from a message's content."""
content = message.get('content')
if isinstance(content, str):
return [content]
if isinstance(content, list):
return [p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text']
return []
def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
"""Extract skill IDs from <$skillId|label> mention tags in messages."""
ids: set[str] = set()
for message in messages:
for text in _get_text_parts(message):
ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text))
return ids
def strip_skill_mentions(messages: list[dict]) -> None:
"""Strip <$skillId|label> mention tags from message content in-place."""
strip_re = re.compile(r'<\$[^>]+>')
for message in messages:
content = message.get('content')
if isinstance(content, str) and strip_re.search(content):
message['content'] = strip_re.sub('', content).strip()
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'text':
text = part.get('text', '')
if strip_re.search(text):
part['text'] = strip_re.sub('', text).strip()
async def process_chat_payload(request, form_data, user, metadata, model):
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
@@ -2217,14 +2288,27 @@ async def process_chat_payload(request, form_data, user, metadata, model):
form_data = apply_params_to_form_data(form_data, model)
log.debug(f'form_data: {form_data}')
# Guided regeneration: extract before it reaches the LLM provider
regeneration_prompt = form_data.pop('regeneration_prompt', None)
# Load messages from DB when available — DB preserves structured 'output' items
# which the frontend strips, causing tool calls to be merged into content.
chat_id = metadata.get('chat_id')
user_message_id = metadata.get('user_message_id')
if chat_id and user_message_id and not chat_id.startswith('local:'):
if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'):
db_messages = await load_messages_from_db(chat_id, user_message_id)
if db_messages:
# Continue: frontend sends assistant_message_id when continuing
# an existing response. Load its content so the LLM sees prior output.
assistant_message_id = metadata.get('assistant_message_id')
if assistant_message_id:
assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
if assistant_message and (assistant_message.get('content') or assistant_message.get('output')):
db_messages.append(
{k: v for k, v in assistant_message.items() if k in ('role', 'content', 'output', 'files')}
)
system_message = get_system_message(form_data.get('messages', []))
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
@@ -2252,13 +2336,19 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Strip files field — it's been incorporated into content
message.pop('files', None)
if regeneration_prompt:
form_data['messages'].append({'role': 'user', 'content': regeneration_prompt})
# Process messages with OR-aligned output items for clean LLM messages
form_data['messages'] = process_messages_with_output(form_data.get('messages', []))
form_data['messages'] = process_messages_with_output(
form_data.get('messages', []),
reasoning_format=get_reasoning_format(model),
)
system_message = get_system_message(form_data.get('messages', []))
if system_message: # Chat Controls/User Settings
try:
form_data = apply_system_prompt_to_body(
form_data = await apply_system_prompt_to_body(
system_message.get('content'), form_data, metadata, user, replace=True
) # Required to handle system prompt variables
except Exception:
@@ -2316,17 +2406,19 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if folder and folder.data:
if 'system_prompt' in folder.data:
form_data = apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
if 'files' in folder.data:
# Defensive: filter to entries the caller can still read.
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
if metadata.get('params', {}).get('function_calling') != 'native':
form_data['files'] = [
*folder.data['files'],
*allowed_files,
*form_data.get('files', []),
]
else:
# Native FC: skip RAG injection, builtin tools
# will read folder knowledge from metadata.
metadata['folder_knowledge'] = folder.data['files']
metadata['folder_knowledge'] = allowed_files
# Model "Knowledge" handling
user_message = get_last_user_message(form_data['messages'])
@@ -2371,6 +2463,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
form_data['files'] = files
variables = form_data.pop('variables', None)
payload_tools = form_data.get('tools', None) # snapshot before filters
# Process the form_data through the pipeline
try:
@@ -2396,8 +2489,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params['__features__'] = features
if features:
if 'voice' in features and features['voice']:
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None:
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != '':
if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True):
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE:
template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE
else:
template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE
@@ -2461,12 +2554,14 @@ async def process_chat_payload(request, form_data, user, metadata, model):
files = form_data.pop('files', None)
form_data.pop('folder_id', None)
# Caller-provided OpenAI-style tools take precedence over server-side
# tool resolution (tool_ids, MCP servers, builtin tools).
payload_tools = form_data.get('tools', None)
# If the original caller provided tools, use them as-is (skip resolution).
# Otherwise, save any tools that filter inlets added for merging later.
inlet_filter_tools = None if payload_tools else form_data.get('tools', None)
# Skills
# Skills — extract IDs from message content (<$skillId|label> tags) so
# persisted chats work without relying on the frontend to send skill_ids.
user_skill_ids = set(form_data.pop('skill_ids', None) or [])
user_skill_ids |= extract_skill_ids_from_messages(form_data.get('messages', []))
model_skill_ids = set(model.get('info', {}).get('meta', {}).get('skillIds', []))
all_skill_ids = user_skill_ids | model_skill_ids
@@ -2502,6 +2597,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
append=True,
)
# Strip <$skillId|label> mention tags so the model doesn't see raw markup.
strip_skill_mentions(form_data.get('messages', []))
prompt = get_last_user_message(form_data['messages'])
# TODO: re-enable URL extraction from prompt
# urls = []
@@ -2520,7 +2618,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
if folder and folder.data and 'files' in folder.data:
files = [f for f in files if f.get('id', None) != folder_id]
files = [*files, *folder.data['files']]
files = [*files, *await get_accessible_folder_files(folder.data['files'], user)]
# files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
# Remove duplicate files based on their content
@@ -2767,6 +2865,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
form_data['tools'] = [
{'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values()
]
if inlet_filter_tools:
form_data['tools'].extend(inlet_filter_tools)
else:
# If the function calling is not native, then call the tools function calling handler
try:
@@ -2797,7 +2897,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# If context is not empty, insert it into the messages
if sources and prompt:
form_data['messages'] = apply_source_context_to_messages(request, form_data['messages'], sources, prompt)
form_data['messages'] = await apply_source_context_to_messages(request, form_data['messages'], sources, prompt)
# If there are citations, add them to the data_items
sources = [
@@ -2958,7 +3058,11 @@ async def background_tasks_handler(ctx):
message = None
messages = []
if 'chat_id' in metadata and not metadata['chat_id'].startswith('local:'):
if (
'chat_id' in metadata
and not metadata['chat_id'].startswith('local:')
and not metadata['chat_id'].startswith('channel:')
):
messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id'])
message = messages_map.get(metadata['message_id']) if messages_map else None
@@ -3038,7 +3142,9 @@ async def background_tasks_handler(ctx):
}
)
if not metadata.get('chat_id', '').startswith('local:'):
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
'chat_id', ''
).startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@@ -3050,7 +3156,9 @@ async def background_tasks_handler(ctx):
except Exception as e:
pass
if not metadata.get('chat_id', '').startswith('local:'): # Only update titles and tags for non-temp chats
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith(
'channel:'
): # Only update titles and tags for non-temp chats
if TASKS.TITLE_GENERATION in tasks:
user_message = get_last_user_message(messages)
if user_message and len(user_message) > 100:
@@ -3174,7 +3282,7 @@ async def outlet_filter_handler(ctx):
if not chat_id or not message_id:
return
is_temp_chat = chat_id.startswith('local:')
is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:')
try:
messages_map = None
@@ -3316,13 +3424,14 @@ async def non_streaming_chat_response_handler(response, ctx):
log.error('Provider returned error (non-streaming): %s', error)
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'error': {'content': error},
},
)
if not metadata['chat_id'].startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'error': {'content': error},
},
)
if isinstance(error, str) or isinstance(error, dict):
await event_emitter(
{
@@ -3331,7 +3440,7 @@ async def non_streaming_chat_response_handler(response, ctx):
}
)
if 'selected_model_id' in response_data:
if 'selected_model_id' in response_data and not metadata['chat_id'].startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@@ -3352,7 +3461,11 @@ async def non_streaming_chat_response_handler(response, ctx):
}
)
title = await Chats.get_chat_title_by_id(metadata['chat_id'])
title = (
await Chats.get_chat_title_by_id(metadata['chat_id'])
if not metadata['chat_id'].startswith('channel:')
else ''
)
# Use output from backend if provided (OR-compliant backends),
# otherwise generate from response content
@@ -3383,17 +3496,18 @@ async def non_streaming_chat_response_handler(response, ctx):
# Save message in the database
usage = normalize_usage(response_data.get('usage', {}) or {})
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'role': 'assistant',
'content': content,
'output': response_output,
**({'usage': usage} if usage else {}),
},
)
if not metadata['chat_id'].startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'role': 'assistant',
'content': content,
'output': response_output,
**({'usage': usage} if usage else {}),
},
)
# Send a webhook notification if the user is not active
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
@@ -3894,6 +4008,12 @@ async def streaming_chat_response_handler(response, ctx):
response_id = response_metadata.pop('response_id', None)
if response_id:
last_response_id = response_id
# Normalize and capture usage for DB persistence
if response_metadata.get('usage'):
response_metadata['usage'] = normalize_usage(response_metadata['usage'])
usage = response_metadata['usage']
processed_data.update(response_metadata)
processed_data.pop('done', None)
@@ -4242,7 +4362,7 @@ async def streaming_chat_response_handler(response, ctx):
if end:
break
if ENABLE_REALTIME_CHAT_SAVE:
if ENABLE_REALTIME_CHAT_SAVE and not metadata['chat_id'].startswith('channel:'):
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
@@ -4269,6 +4389,8 @@ async def streaming_chat_response_handler(response, ctx):
'data': data,
}
)
except (asyncio.CancelledError, KeyboardInterrupt):
raise
except Exception as e:
done = 'data: [DONE]' in line
if done:
@@ -4611,7 +4733,7 @@ async def streaming_chat_response_handler(response, ctx):
)
source_context = source_context.strip()
if source_context:
rag_content = rag_template(
rag_content = await rag_template(
request.app.state.config.RAG_TEMPLATE,
source_context,
user_message,
@@ -4656,16 +4778,21 @@ async def streaming_chat_response_handler(response, ctx):
**form_data,
'model': model_id,
'stream': True,
'metadata': metadata,
}
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
system_message = get_system_message(form_data['messages'])
new_form_data['messages'] = (
[system_message] if system_message else []
) + convert_output_to_messages(output, raw=True)
) + convert_output_to_messages(
output, raw=True, reasoning_format=get_reasoning_format(model)
)
new_form_data['previous_response_id'] = last_response_id
else:
tool_messages = convert_output_to_messages(output, raw=True)
tool_messages = convert_output_to_messages(
output, raw=True, reasoning_format=get_reasoning_format(model)
)
# Chat Completions providers don't support multimodal
# tool messages. Extract images into a user message.
@@ -4762,8 +4889,7 @@ async def streaming_chat_response_handler(response, ctx):
code = sanitize_code(code)
if CODE_INTERPRETER_BLOCKED_MODULES:
blocking_code = textwrap.dedent(
f"""
blocking_code = textwrap.dedent(f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
@@ -4779,8 +4905,7 @@ async def streaming_chat_response_handler(response, ctx):
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
"""
)
""")
code = blocking_code + '\n' + code
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
@@ -4816,6 +4941,11 @@ async def streaming_chat_response_handler(response, ctx):
log.debug(f'Code interpreter output: {ci_output}')
# Handle error responses from event_caller
# (e.g. session disconnected, timeout)
if isinstance(ci_output, dict) and ci_output.get('error'):
ci_output = {'stderr': ci_output['error']}
if isinstance(ci_output, dict):
stdout = ci_output.get('stdout', '')
@@ -4879,9 +5009,12 @@ async def streaming_chat_response_handler(response, ctx):
**form_data,
'model': model_id,
'stream': True,
'metadata': metadata,
'messages': [
*form_data['messages'],
*convert_output_to_messages(output, raw=True),
*convert_output_to_messages(
output, raw=True, reasoning_format=get_reasoning_format(model)
),
],
}
@@ -4905,7 +5038,11 @@ async def streaming_chat_response_handler(response, ctx):
if item.get('status') == 'in_progress':
item['status'] = 'completed'
title = await Chats.get_chat_title_by_id(metadata['chat_id'])
title = (
await Chats.get_chat_title_by_id(metadata['chat_id'])
if not metadata['chat_id'].startswith('channel:')
else ''
)
data = {
'done': True,
'content': serialize_output(output),
@@ -4914,30 +5051,31 @@ async def streaming_chat_response_handler(response, ctx):
**({'usage': usage} if usage else {}),
}
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
},
)
elif usage:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True, 'usage': usage},
)
else:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
if not metadata['chat_id'].startswith('channel:'):
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
},
)
elif usage:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True, 'usage': usage},
)
else:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
# Send a webhook notification if the user is not active
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
@@ -4971,13 +5109,22 @@ async def streaming_chat_response_handler(response, ctx):
await outlet_filter_handler(ctx)
except asyncio.CancelledError:
log.warning('Task was cancelled!')
try:
await asyncio.shield(event_emitter({'type': 'chat:tasks:cancel'}))
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
await asyncio.shield(
Chats.upsert_message_to_chat_by_id_and_message_id(
# Close the response body iterator to trigger cleanup
# in stream_wrapper's finally block and release the
# upstream connection. Without this, the async
# generator is orphaned and may spin in anyio internals.
if hasattr(response, 'body_iterator') and hasattr(response.body_iterator, 'aclose'):
try:
await asyncio.shield(response.body_iterator.aclose())
except (asyncio.CancelledError, Exception):
pass
async def save_cancelled_state():
await event_emitter({'type': 'chat:tasks:cancel'})
if not metadata['chat_id'].startswith('channel:'):
if not ENABLE_REALTIME_CHAT_SAVE:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
@@ -4986,16 +5133,16 @@ async def streaming_chat_response_handler(response, ctx):
'output': output,
},
)
)
else:
await asyncio.shield(
Chats.upsert_message_to_chat_by_id_and_message_id(
else:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
)
except Exception:
try:
await asyncio.shield(save_cancelled_state())
except (asyncio.CancelledError, Exception):
pass
raise # re-raise CancelledError for proper propagation
+78 -38
View File
@@ -129,7 +129,11 @@ def get_content_from_message(message: dict) -> Optional[str]:
return None
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
def convert_output_to_messages(
output: list,
raw: bool = False,
reasoning_format: str | None = None,
) -> list[dict]:
"""
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
@@ -139,8 +143,14 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
Args:
output: List of OR-aligned output items (Responses API format).
raw: If True, include reasoning blocks (with original tags) and code
interpreter blocks for LLM re-processing follow-ups.
raw: If True, include code interpreter blocks for LLM re-processing
follow-ups.
reasoning_format: How to include reasoning blocks in the output:
- None: skip reasoning (default, safe for strict providers).
- ``'think_tags'``: wrap in ``<think>`` tags inside content
(for Ollama, which expects reasoning as tagged content).
- ``'reasoning_content'``: set as ``reasoning_content`` top-level field
(for llama.cpp, which routes it via the chat template).
"""
if not output or not isinstance(output, list):
return []
@@ -148,19 +158,26 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
messages = []
pending_tool_calls = []
pending_content = []
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
def flush_pending():
nonlocal pending_content, pending_tool_calls
if pending_content or pending_tool_calls:
messages.append(
{
'role': 'assistant',
'content': '\n'.join(pending_content) if pending_content else '',
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
}
)
pending_content = []
pending_tool_calls = []
nonlocal pending_content, pending_tool_calls, pending_reasoning
if not pending_content and not pending_tool_calls and not pending_reasoning:
return
message = {
'role': 'assistant',
'content': '\n'.join(pending_content) if pending_content else '',
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
}
if pending_reasoning:
message['reasoning_content'] = '\n'.join(pending_reasoning)
messages.append(message)
pending_content = []
pending_tool_calls = []
pending_reasoning = []
for item in output:
item_type = item.get('type', '')
@@ -231,27 +248,26 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
)
elif item_type == 'reasoning':
if raw:
# Include reasoning with original tags for LLM re-processing
reasoning_text = ''
source_list = item.get('summary', []) or item.get('content', [])
for part in source_list:
if part.get('type') == 'output_text':
reasoning_text += part.get('text', '')
elif 'text' in part:
reasoning_text += part.get('text', '')
if not reasoning_format:
continue
if reasoning_text:
reasoning_text = ''
source_list = item.get('summary', []) or item.get('content', [])
for part in source_list:
if part.get('type') == 'output_text':
reasoning_text += part.get('text', '')
elif 'text' in part:
reasoning_text += part.get('text', '')
if reasoning_text:
if reasoning_format == 'think_tags':
# Ollama: embed in content with the item's original tags
start_tag = item.get('start_tag', '<think>')
end_tag = item.get('end_tag', '</think>')
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
# NOTE: Some providers (e.g. Moonshot/Kimi K2.5) require
# reasoning_content as a top-level field on assistant
# messages. This should be handled externally via a
# pipeline filter or connection-level middleware, not
# here — adding it universally breaks strict providers
# (OpenAI, Vertex AI, Azure) that reject unknown fields.
# else: skip reasoning blocks for normal LLM messages
elif reasoning_format == 'reasoning_content':
# llama.cpp: collect for reasoning_content field
pending_reasoning.append(reasoning_text)
elif item_type == 'open_webui:code_interpreter':
# Always include code interpreter content so the LLM knows
@@ -597,6 +613,9 @@ def sanitize_text_for_db(text: str) -> str:
"""Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage."""
if not isinstance(text, str):
return text
# Fast path: skip work when there are no null bytes (the common case)
if '\x00' not in text:
return text
# Remove null bytes
text = text.replace('\x00', '').replace('\u0000', '')
# Remove invalid UTF-8 surrogate characters that can cause encoding errors
@@ -608,17 +627,38 @@ def sanitize_text_for_db(text: str) -> str:
return text
def sanitize_data_for_db(obj):
"""Recursively sanitize all strings in a data structure for database storage."""
def _strip_null_bytes_deep(obj):
"""Inner recursive walk — only called when null bytes are known to be present."""
if isinstance(obj, str):
return sanitize_text_for_db(obj)
elif isinstance(obj, dict):
return {k: sanitize_data_for_db(v) for k, v in obj.items()}
return {k: _strip_null_bytes_deep(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize_data_for_db(v) for v in obj]
return [_strip_null_bytes_deep(v) for v in obj]
return obj
def sanitize_data_for_db(obj):
"""Recursively sanitize all strings in a data structure for database storage.
Performs a fast pre-check: serializes the structure once and scans for
null bytes. If none are found (the overwhelmingly common case), the
original object is returned immediately, skipping the expensive
recursive walk.
"""
if isinstance(obj, str):
return sanitize_text_for_db(obj)
# Fast path: check for null bytes in the serialized form.
# json.dumps is implemented in C and much faster than a Python-level
# recursive walk over every leaf string.
try:
if '\x00' not in json.dumps(obj, ensure_ascii=False):
return obj
except (TypeError, ValueError):
pass
return _strip_null_bytes_deep(obj)
def sanitize_metadata(metadata: dict) -> dict:
"""
Return a JSON-safe copy of a metadata dict for database storage.
@@ -843,9 +883,9 @@ def throttle(interval: float = 10.0):
last_calls = {}
lock = threading.Lock()
def wrapper(*args, **kwargs):
async def wrapper(*args, **kwargs):
if interval is None:
return func(*args, **kwargs)
return await func(*args, **kwargs)
key = (args, freeze(kwargs))
now = time.time()
@@ -855,7 +895,7 @@ def throttle(interval: float = 10.0):
if now - last_calls.get(key, 0) < interval:
return None
last_calls[key] = now
return func(*args, **kwargs)
return await func(*args, **kwargs)
return wrapper
+5 -2
View File
@@ -47,6 +47,7 @@ async def fetch_ollama_models(request: Request, user: UserModel = None):
'created': int(time.time()),
'owned_by': 'ollama',
'ollama': model,
'loaded': 'expires_at' in model,
'connection_type': model.get('connection_type', 'local'),
'tags': model.get('tags', []),
}
@@ -199,6 +200,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'connection_type': connection_type,
'preset': True,
**({'pipe': pipe} if pipe is not None else {}),
**({'provider': base_model.get('provider')} if base_model and base_model.get('provider') else {}),
**({'loaded': base_model.get('loaded')} if base_model and base_model.get('loaded') is not None else {}),
}
info = custom_model.model_dump()
@@ -287,9 +290,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# imported/custom model configs may reference tools or filters the user
# hasn't installed, and trying to load those would cause persistent
# "Failed to load function module" log spam on every model refresh.
for function_id in functions_by_id:
for function_id, function in functions_by_id.items():
try:
await get_function_module_from_cache(request, function_id)
await get_function_module_from_cache(request, function_id, function=function)
except Exception as e:
log.debug(f'Failed to load function module for {function_id}: {e}')
+106 -45
View File
@@ -1,4 +1,5 @@
import base64
from dataclasses import dataclass, field
import copy
import hashlib
import logging
@@ -37,6 +38,7 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF
from open_webui.config import (
DEFAULT_USER_ROLE,
ENABLE_OAUTH_SIGNUP,
OAUTH_CLIENT_TIMEOUT,
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
OAUTH_PROVIDERS,
@@ -69,6 +71,7 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@@ -99,6 +102,7 @@ class OAuthClientMetadata(MCPOAuthClientMetadata):
class OAuthClientInformationFull(OAuthClientMetadata):
issuer: Optional[str] = None # URL of the OAuth server that issued this client
resource: Optional[str] = None # RFC 8707 resource indicator for JWT audience
client_id: str
client_secret: str | None = None
@@ -289,12 +293,34 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
return parsed, base_url
async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
"""
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
"""
@dataclass
class ProtectedResourceMetadata:
"""RFC 9728 Protected Resource Metadata fields relevant to OAuth flows."""
resource: str | None = None
authorization_servers: list[str] = field(default_factory=list)
def get_discovery_urls(self, server_url: str) -> list[str]:
"""Build all candidate OAuth discovery URLs from this metadata and the server URL."""
urls = []
for auth_server in self.authorization_servers:
urls.extend(_build_well_known_urls(auth_server.rstrip('/')))
urls.extend(_build_well_known_urls(server_url))
return urls
async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceMetadata:
"""
Fetch RFC 9728 Protected Resource Metadata from an MCP server.
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
Returns:
ProtectedResourceMetadata with the resource indicator (RFC 8707)
and authorization server URLs discovered from the metadata document.
"""
authorization_servers = []
resource = None
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
@@ -304,57 +330,66 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status == 401:
resource_metadata_urls = []
match = re.search(
r'resource_metadata=(?:"([^"]+)"|([^\s,]+))',
response.headers.get('WWW-Authenticate', ''),
)
if match:
resource_metadata_url = match.group(1) or match.group(2)
log.debug(f'Found resource_metadata URL: {resource_metadata_url}')
resource_metadata_urls = [match.group(1) or match.group(2)]
log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}')
else:
# Fall back to well-known resource metadata URIs (RFC 9728 §4.2)
parsed, base_url = get_parsed_and_base_url(server_url)
if parsed.path and parsed.path != '/':
path = parsed.path.rstrip('/')
resource_metadata_urls.append(
urllib.parse.urljoin(base_url, f'/.well-known/oauth-protected-resource{path}')
)
resource_metadata_urls.append(
urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource')
)
log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}')
# Step 2: Fetch Protected Resource metadata
async with session.get(
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resource_response:
if resource_response.status == 200:
resource_metadata = await resource_response.json()
# Fetch Protected Resource metadata from candidate URLs
for resource_metadata_url in resource_metadata_urls:
try:
async with session.get(
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resource_response:
if resource_response.status == 200:
resource_metadata = await resource_response.json()
# Step 3: Extract authorization_servers
servers = resource_metadata.get('authorization_servers', [])
if servers:
authorization_servers = servers
log.debug(f'Discovered authorization servers: {servers}')
resource = resource_metadata.get('resource') or None
if resource:
log.debug(f'Discovered resource indicator: {resource}')
servers = resource_metadata.get('authorization_servers', [])
if servers:
authorization_servers = servers
log.debug(f'Discovered authorization servers: {servers}')
break
except Exception as e:
log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}')
continue
except Exception as e:
log.debug(f'MCP Protected Resource discovery failed: {e}')
discovery_urls = []
for auth_server in authorization_servers:
auth_server = auth_server.rstrip('/')
discovery_urls.extend(
[
f'{auth_server}/.well-known/oauth-authorization-server',
f'{auth_server}/.well-known/openid-configuration',
]
)
return discovery_urls
return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers)
async def get_discovery_urls(server_url) -> list[str]:
urls = await get_authorization_server_discovery_urls(server_url)
def _build_well_known_urls(server_url: str) -> list[str]:
"""Build RFC 8414 / OIDC Discovery well-known URLs for a server URL."""
parsed, base_url = get_parsed_and_base_url(server_url)
urls = []
if parsed.path and parsed.path != '/':
# Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery
tenant = parsed.path.rstrip('/')
path = parsed.path.rstrip('/')
urls.extend(
[
urllib.parse.urljoin(
base_url,
f'/.well-known/oauth-authorization-server{tenant}',
),
urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{tenant}'),
urllib.parse.urljoin(base_url, f'{tenant}/.well-known/openid-configuration'),
urllib.parse.urljoin(base_url, f'/.well-known/oauth-authorization-server{path}'),
urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{path}'),
urllib.parse.urljoin(base_url, f'{path}/.well-known/openid-configuration'),
]
)
@@ -368,6 +403,12 @@ async def get_discovery_urls(server_url) -> list[str]:
return urls
async def get_discovery_urls(server_url) -> list[str]:
"""Convenience: get all OAuth discovery URLs for a server URL."""
metadata = await get_protected_resource_metadata(server_url)
return metadata.get_discovery_urls(server_url)
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
# This is not currently supported.
async def get_oauth_client_info_with_dynamic_client_registration(
@@ -390,7 +431,9 @@ async def get_oauth_client_info_with_dynamic_client_registration(
)
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
discovery_urls = await get_discovery_urls(oauth_server_url)
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
resource = resource_metadata.resource
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as oauth_server_metadata_response:
@@ -450,8 +493,9 @@ async def get_oauth_client_info_with_dynamic_client_registration(
oauth_client_info = OAuthClientInformationFull.model_validate(
{
**registration_response_json,
**{'issuer': oauth_server_metadata_url},
**{'server_metadata': oauth_server_metadata},
'issuer': oauth_server_metadata_url,
'server_metadata': oauth_server_metadata,
'resource': resource,
}
)
log.info(
@@ -500,7 +544,9 @@ async def get_oauth_client_info_with_static_credentials(
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
discovery_urls = await get_discovery_urls(oauth_server_url)
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
resource = resource_metadata.resource
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
@@ -539,6 +585,7 @@ async def get_oauth_client_info_with_static_credentials(
token_endpoint_auth_method=token_endpoint_auth_method,
issuer=oauth_server_metadata_url,
server_metadata=oauth_server_metadata,
resource=resource,
)
log.info(
@@ -581,6 +628,7 @@ class OAuthClientManager:
'client_secret': oauth_client_info.client_secret,
'client_kwargs': {
'follow_redirects': True,
**({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}),
**({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}),
**(
{'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method}
@@ -693,7 +741,7 @@ class OAuthClientManager:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
authorization_url,
allow_redirects=False,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.status < 400:
@@ -856,6 +904,11 @@ class OAuthClientManager:
'refresh_token': token_data['refresh_token'],
'client_id': client.client_id,
}
# RFC 8707: include resource indicator so refreshed tokens retain correct audience
client_info = self.get_client_info(client_id)
if client_info and client_info.resource:
refresh_data['resource'] = client_info.resource
if hasattr(client, 'client_secret') and client.client_secret:
refresh_data['client_secret'] = client.client_secret
@@ -908,7 +961,11 @@ class OAuthClientManager:
redirect_uri = client_info.redirect_uris[0] if client_info.redirect_uris else None
redirect_uri_str = str(redirect_uri) if redirect_uri else None
return await client.authorize_redirect(request, redirect_uri_str)
# RFC 8707: pass resource indicator so the IdP sets the correct JWT audience
kwargs = {}
if client_info.resource:
kwargs['resource'] = client_info.resource
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
async def handle_callback(self, request, client_id: str, user_id: str, response):
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
@@ -923,7 +980,11 @@ class OAuthClientManager:
# The Authlib client already has these configured during add_client().
# Passing them again causes Authlib to concatenate them (e.g., "ID1,ID1"),
# which results in 401 errors from the token endpoint. (Fix for #19823)
token = await client.authorize_access_token(request)
# RFC 8707: pass resource indicator for correct JWT audience on token exchange
token_kwargs = {}
if client_info and client_info.resource:
token_kwargs['resource'] = client_info.resource
token = await client.authorize_access_token(request, **token_kwargs)
# Validate that we received a proper token response
# If token exchange failed (e.g., 401), we may get an error response instead
+2 -2
View File
@@ -13,7 +13,7 @@ import json
# What goes out cannot be taken back. Let it be shaped
# well before it leaves this place.
# inplace function: form_data is modified
def apply_system_prompt_to_body(
async def apply_system_prompt_to_body(
system: Optional[str],
form_data: dict,
metadata: Optional[dict] = None,
@@ -30,7 +30,7 @@ def apply_system_prompt_to_body(
system = prompt_variables_template(system, variables)
# Legacy (API Usage)
system = prompt_template(system, user)
system = await prompt_template(system, user)
if replace:
form_data['messages'] = replace_system_message_content(system, form_data.get('messages', []))
+19 -6
View File
@@ -14,7 +14,7 @@ from open_webui.env import (
OFFLINE_MODE,
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
)
from open_webui.models.functions import Functions
from open_webui.models.functions import FunctionModel, Functions
from open_webui.models.tools import Tools
log = logging.getLogger(__name__)
@@ -335,13 +335,16 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
return tool_module, frontmatter
async def get_function_module_from_cache(request, function_id, load_from_db=True):
async def get_function_module_from_cache(
request, function_id, function: FunctionModel | None = None, load_from_db=True
):
if load_from_db:
# Always load from the database by default
# This is useful for hooks like "inlet" or "outlet" where the content might change
# and we want to ensure the latest content is used.
function = await Functions.get_function_by_id(function_id)
if function is None:
function = await Functions.get_function_by_id(function_id)
if not function:
raise Exception(f'Function not found: {function_id}')
content = function.content
@@ -380,7 +383,11 @@ async def get_function_module_from_cache(request, function_id, load_from_db=True
return function_module, function_type, frontmatter
_installed_requirements = set()
def install_frontmatter_requirements(requirements: str):
global _installed_requirements
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.')
return
@@ -392,12 +399,18 @@ def install_frontmatter_requirements(requirements: str):
if requirements:
try:
req_list = [req.strip() for req in requirements.split(',')]
log.info(f'Installing requirements: {" ".join(req_list)}')
new_reqs = [req for req in req_list if req and req not in _installed_requirements]
if not new_reqs:
return
log.info(f'Installing requirements: {" ".join(new_reqs)}')
subprocess.check_call(
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + req_list + PIP_PACKAGE_INDEX_OPTIONS
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + new_reqs + PIP_PACKAGE_INDEX_OPTIONS
)
_installed_requirements.update(new_reqs)
except Exception as e:
log.error(f'Error installing packages: {" ".join(req_list)}')
log.error(f'Error installing packages: {" ".join(new_reqs)}')
raise e
else:
+3 -1
View File
@@ -21,6 +21,8 @@ from open_webui.env import (
log = logging.getLogger(__name__)
MAX_RETRY_COUNT = REDIS_SENTINEL_MAX_RETRY_COUNT
# Let not our connections be timed out but deliver them from
# partition. For the cache and the socket and the uptime
@@ -38,7 +40,7 @@ class SentinelRedisProxy:
def _master(self):
return self._sentinel.master_for(self._service, **self._kw)
async def __getattr__(self, item):
def __getattr__(self, item):
master = self._master()
orig_attr = getattr(master, item)
+9
View File
@@ -135,6 +135,9 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
async def convert_streaming_response_ollama_to_openai(ollama_streaming_response):
has_tool_calls = False
# All chunks in a single completion must share the same id (OpenAI spec).
completion_id = f'chatcmpl-{str(uuid4())}'
first = True
async for data in ollama_streaming_response.body_iterator:
data = json.loads(data)
@@ -155,6 +158,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
usage = convert_ollama_usage_to_openai(data)
data = openai_chat_chunk_message_template(model, message_content, reasoning_content, openai_tool_calls, usage)
data['id'] = completion_id
# First chunk must carry delta.role (OpenAI spec).
if first:
data['choices'][0]['delta']['role'] = 'assistant'
first = False
if done and has_tool_calls:
data['choices'][0]['finish_reason'] = 'tool_calls'
+32 -17
View File
@@ -35,7 +35,7 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
return template
def prompt_template(template: str, user: Optional[Any] = None) -> str:
async def prompt_template(template: str, user: Optional[Any] = None) -> str:
USER_VARIABLES = {}
if user:
@@ -58,6 +58,19 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
except Exception as e:
pass
# Resolve user groups from DB only when the template uses {{USER_GROUPS}}
groups = ''
if '{{USER_GROUPS}}' in template:
user_id = user.get('id')
if user_id:
try:
from open_webui.models.groups import Groups
user_groups = await Groups.get_groups_by_member_id(user_id)
groups = ', '.join(g.name for g in user_groups)
except Exception:
pass
USER_VARIABLES = {
'name': str(user.get('name')),
'email': str(user.get('email')),
@@ -66,6 +79,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
'gender': str(user.get('gender')),
'birth_date': str(birth_date),
'age': str(age),
'groups': groups,
}
# Get the current date
@@ -88,6 +102,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
template = template.replace('{{USER_BIRTH_DATE}}', USER_VARIABLES.get('birth_date', 'Unknown'))
template = template.replace('{{USER_AGE}}', str(USER_VARIABLES.get('age', 'Unknown')))
template = template.replace('{{USER_LOCATION}}', USER_VARIABLES.get('location', 'Unknown'))
template = template.replace('{{USER_GROUPS}}', USER_VARIABLES.get('groups', ''))
return template
@@ -243,11 +258,11 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No
# Let the context given here not distort the question,
# but illuminate it, so that the answer serves the one who asked.
def rag_template(template: str, context: str, query: str):
async def rag_template(template: str, context: str, query: str):
if template.strip() == '':
template = DEFAULT_RAG_TEMPLATE
template = prompt_template(template)
template = await prompt_template(template)
if '[context]' not in template and '{{CONTEXT}}' not in template:
log.debug("WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder.")
@@ -282,51 +297,51 @@ def rag_template(template: str, context: str, query: str):
return template
def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
async def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
async def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
async def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
async def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str:
async def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str:
template = replace_prompt_variable(template, prompt)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def autocomplete_generation_template(
async def autocomplete_generation_template(
template: str,
prompt: str,
messages: Optional[list[dict]] = None,
@@ -337,16 +352,16 @@ def autocomplete_generation_template(
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
async def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(template, user)
template = await prompt_template(template, user)
return template
+80 -27
View File
@@ -17,8 +17,10 @@ high-cardinality label sets.
from __future__ import annotations
import datetime
import logging
import time
from typing import Dict, List, Sequence, Any
from typing import Dict, Iterable, List, Optional
from base64 import b64encode
from fastapi import FastAPI, Request
@@ -36,6 +38,8 @@ from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import Resource
from sqlalchemy import Engine, func, select
from sqlalchemy.orm import Session
from open_webui.env import (
OTEL_SERVICE_NAME,
@@ -46,7 +50,47 @@ from open_webui.env import (
OTEL_METRICS_EXPORTER_OTLP_INSECURE,
OTEL_METRICS_EXPORT_INTERVAL_MILLIS,
)
from open_webui.models.users import Users
from open_webui.models.users import User
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Sync DB helpers for OTel gauge callbacks
#
# The OTel Python SDK calls observable-instrument callbacks *synchronously*
# from a background collection thread — async callbacks are NOT supported
# (the SDK does not ``await`` the return value).
#
# Rather than bridging into the async event loop, we run plain synchronous
# SQL queries using the sync engine that is already available at setup time.
# This avoids any cross-thread / cross-loop concerns entirely.
# ---------------------------------------------------------------------------
def _count_total_users(db_engine: Engine) -> Optional[int]:
"""Return the total number of registered users (sync)."""
with Session(db_engine) as session:
return session.execute(select(func.count()).select_from(User)).scalar()
def _count_active_users(db_engine: Engine) -> Optional[int]:
"""Return the number of users active within the last 3 minutes (sync)."""
three_minutes_ago = int(time.time()) - 180
with Session(db_engine) as session:
return session.execute(
select(func.count()).select_from(User).filter(User.last_active_at >= three_minutes_ago)
).scalar()
def _count_users_active_today(db_engine: Engine) -> Optional[int]:
"""Return the number of users active since midnight today (sync)."""
now = int(datetime.datetime.now().timestamp())
today_midnight = now - (now % 86400)
with Session(db_engine) as session:
return session.execute(
select(func.count()).select_from(User).filter(User.last_active_at > today_midnight)
).scalar()
def _build_meter_provider(resource: Resource) -> MeterProvider:
@@ -106,7 +150,7 @@ def _build_meter_provider(resource: Resource) -> MeterProvider:
return provider
def setup_metrics(app: FastAPI, resource: Resource) -> None:
def setup_metrics(app: FastAPI, resource: Resource, db_engine: Engine) -> None:
"""Attach OTel metrics middleware to *app* and initialise provider."""
metrics.set_meter_provider(_build_meter_provider(resource))
@@ -124,32 +168,46 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
unit='ms',
)
async def observe_active_users(
options: metrics.CallbackOptions,
) -> Sequence[metrics.Observation]:
return [
metrics.Observation(
value=await Users.get_active_user_count(),
)
]
# -- Observable gauge callbacks ----------------------------------------
# These are called synchronously by the OTel SDK from a background
# collection thread. They use the sync DB engine directly — no async
# bridging required.
async def observe_total_registered_users(
def observe_total_users(
options: metrics.CallbackOptions,
) -> Sequence[metrics.Observation]:
# IMPORTANT: Use get_num_users() for efficient COUNT(*) query.
# Do NOT use len(get_users()["users"]) - it loads ALL user records into memory,
# causing connection pool exhaustion on high-latency databases (e.g., Aurora).
return [
metrics.Observation(
value=await Users.get_num_users() or 0,
)
]
) -> Iterable[metrics.Observation]:
try:
value = _count_total_users(db_engine)
if value is not None:
yield metrics.Observation(value=value)
except Exception:
logger.debug('Failed to observe total users', exc_info=True)
def observe_active_users(
options: metrics.CallbackOptions,
) -> Iterable[metrics.Observation]:
try:
value = _count_active_users(db_engine)
if value is not None:
yield metrics.Observation(value=value)
except Exception:
logger.debug('Failed to observe active users', exc_info=True)
def observe_users_active_today(
options: metrics.CallbackOptions,
) -> Iterable[metrics.Observation]:
try:
value = _count_users_active_today(db_engine)
if value is not None:
yield metrics.Observation(value=value)
except Exception:
logger.debug('Failed to observe users active today', exc_info=True)
meter.create_observable_gauge(
name='webui.users.total',
description='Total number of registered users',
unit='users',
callbacks=[observe_total_registered_users],
callbacks=[observe_total_users],
)
meter.create_observable_gauge(
@@ -159,11 +217,6 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
callbacks=[observe_active_users],
)
async def observe_users_active_today(
options: metrics.CallbackOptions,
) -> Sequence[metrics.Observation]:
return [metrics.Observation(value=await Users.get_num_users_active_today())]
meter.create_observable_gauge(
name='webui.users.active.today',
description='Number of users active since midnight today',
+1 -1
View File
@@ -55,4 +55,4 @@ def setup(app: FastAPI, db_engine: Engine):
# set up metrics only if enabled
if ENABLE_OTEL_METRICS:
setup_metrics(app, resource)
setup_metrics(app, resource, db_engine)
+109 -39
View File
@@ -1,4 +1,5 @@
import base64
import copy
import inspect
import logging
import re
@@ -7,6 +8,7 @@ import aiohttp
import asyncio
import yaml
import json
from urllib.parse import quote, urlencode
from pydantic import BaseModel
from pydantic.fields import FieldInfo
@@ -45,6 +47,7 @@ from open_webui.utils.access_control import has_access, has_connection_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
@@ -54,7 +57,7 @@ from open_webui.env import (
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
REDIS_KEY_PREFIX,
)
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.headers import include_user_info_headers, get_custom_headers
from open_webui.tools.builtin import (
search_web,
fetch_url,
@@ -100,7 +103,6 @@ from open_webui.tools.builtin import (
delete_calendar_event,
)
import copy
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
@@ -189,10 +191,11 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
log.warning(f'Access denied to tool {tool_id} for user {user.id}')
continue
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = await load_tool_module_by_id(tool_id)
module = request.app.state.TOOLS.get(tool_id)
if module is None or request.app.state.TOOL_CONTENTS.get(tool_id) != tool.content:
module, _ = await load_tool_module_by_id(tool_id, content=tool.content)
request.app.state.TOOLS[tool_id] = module
request.app.state.TOOL_CONTENTS[tool_id] = tool.content
__user__ = {
**extra_params['__user__'],
@@ -336,8 +339,9 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
connection_headers = tool_server_connection.get('headers', None)
if connection_headers and isinstance(connection_headers, dict):
for key, value in connection_headers.items():
headers[key] = value
metadata = extra_params.get('__metadata__', {})
custom_headers = get_custom_headers(connection_headers, user, metadata)
headers.update(custom_headers)
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
@@ -726,7 +730,6 @@ def clean_properties(schema: dict):
def clean_openai_tool_schema(spec: dict) -> dict:
import copy
cleaned_spec = copy.deepcopy(spec)
@@ -759,6 +762,11 @@ def get_tool_specs(tool_module: object) -> list[dict]:
return specs
# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*)
# and non-operation path-item fields (summary, description, servers, parameters).
OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'}
def resolve_schema(schema, components, resolved_schemas=None):
"""
Recursively resolves a JSON schema using OpenAPI components.
@@ -795,6 +803,13 @@ def resolve_schema(schema, components, resolved_schemas=None):
if 'items' in resolved_schema:
resolved_schema['items'] = resolve_schema(resolved_schema['items'], components)
# Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref
for keyword in ('oneOf', 'anyOf', 'allOf'):
if keyword in resolved_schema and isinstance(resolved_schema[keyword], list):
resolved_schema[keyword] = [
resolve_schema(inner, components, resolved_schemas) for inner in resolved_schema[keyword]
]
return resolved_schema
@@ -811,7 +826,20 @@ def convert_openapi_to_tool_payload(openapi_spec):
tool_payload = []
for path, methods in openapi_spec.get('paths', {}).items():
if not isinstance(methods, dict):
continue
# Path-level parameters apply to all operations under this path
# unless overridden at the operation level (matched by name + in).
path_level_params = methods.get('parameters', [])
if not isinstance(path_level_params, list):
path_level_params = []
for method, operation in methods.items():
if method not in OPENAPI_HTTP_METHODS:
continue
if not isinstance(operation, dict):
continue
if operation.get('operationId'):
tool = {
'name': operation.get('operationId'),
@@ -822,7 +850,21 @@ def convert_openapi_to_tool_payload(openapi_spec):
'parameters': {'type': 'object', 'properties': {}, 'required': []},
}
for param in operation.get('parameters', []):
# Merge path-level and operation-level parameters.
# Operation-level params override path-level params with the
# same (name, in) pair per the OpenAPI spec.
op_params = operation.get('parameters', [])
if not isinstance(op_params, list):
op_params = []
merged_params = {}
for param in path_level_params:
if isinstance(param, dict) and param.get('name'):
merged_params[(param['name'], param.get('in', ''))] = param
for param in op_params:
if isinstance(param, dict) and param.get('name'):
merged_params[(param['name'], param.get('in', ''))] = param
for param in merged_params.values():
param_name = param.get('name')
if not param_name:
continue
@@ -871,29 +913,40 @@ def convert_openapi_to_tool_payload(openapi_spec):
async def set_tool_servers(request: Request):
request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS)
try:
request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS)
except Exception as e:
log.error(f'Error fetching tool server data: {e}')
request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or []
if request.app.state.redis is not None:
await request.app.state.redis.set(
f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS)
)
try:
if request.app.state.redis is not None:
await request.app.state.redis.set(
f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS)
)
except Exception as e:
log.error(f'Error caching tool_servers to Redis: {e}')
return request.app.state.TOOL_SERVERS
async def get_tool_servers(request: Request):
tool_servers = []
if request.app.state.redis is not None:
try:
tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers'))
request.app.state.TOOL_SERVERS = tool_servers
except Exception as e:
log.error(f'Error fetching tool_servers from Redis: {e}')
try:
tool_servers = []
if request.app.state.redis is not None:
try:
tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers'))
request.app.state.TOOL_SERVERS = tool_servers
except Exception as e:
log.error(f'Error fetching tool_servers from Redis: {e}')
if not tool_servers:
tool_servers = await set_tool_servers(request)
if not tool_servers:
tool_servers = await set_tool_servers(request)
return tool_servers
return tool_servers
except Exception as e:
log.error(f'Failed to load tool servers, skipping: {e}')
return getattr(request.app.state, 'TOOL_SERVERS', None) or []
async def get_terminal_cwd(
@@ -1154,22 +1207,17 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A
error_body = await response.json()
raise Exception(error_body)
text_content = None
text_content = await response.text()
# Check if URL ends with .yaml or .yml to determine format
if url.lower().endswith(('.yaml', '.yml')):
text_content = await response.text()
res = yaml.safe_load(text_content)
else:
text_content = await response.text()
try:
res = json.loads(text_content)
except json.JSONDecodeError:
try:
res = json.loads(text_content)
except json.JSONDecodeError:
# Fall back to YAML for non-.yml URLs that aren't valid JSON
res = yaml.safe_load(text_content)
except Exception as e:
raise e
except Exception as err:
log.exception(f'Could not fetch tool server spec from {url}')
@@ -1297,7 +1345,11 @@ async def execute_tool_server(
matching_route = None
for route_path, methods in paths.items():
if not isinstance(methods, dict):
continue
for http_method, operation in methods.items():
if http_method not in OPENAPI_HTTP_METHODS:
continue
if isinstance(operation, dict) and operation.get('operationId') == name:
matching_route = (route_path, methods)
break
@@ -1311,6 +1363,10 @@ async def execute_tool_server(
method_entry = None
for http_method, operation in methods.items():
if http_method not in OPENAPI_HTTP_METHODS:
continue
if not isinstance(operation, dict):
continue
if operation.get('operationId') == name:
method_entry = (http_method.lower(), operation)
break
@@ -1324,7 +1380,22 @@ async def execute_tool_server(
query_params = {}
body_params = {}
for param in operation.get('parameters', []):
# Merge path-level and operation-level parameters for execution.
path_level_params = methods.get('parameters', [])
if not isinstance(path_level_params, list):
path_level_params = []
op_params = operation.get('parameters', [])
if not isinstance(op_params, list):
op_params = []
merged_params = {}
for param in path_level_params:
if isinstance(param, dict) and param.get('name'):
merged_params[(param['name'], param.get('in', ''))] = param
for param in op_params:
if isinstance(param, dict) and param.get('name'):
merged_params[(param['name'], param.get('in', ''))] = param
for param in merged_params.values():
param_name = param.get('name')
if not param_name:
continue
@@ -1342,11 +1413,10 @@ async def execute_tool_server(
final_url = f'{url.rstrip("/")}{route_path}'
for key, value in path_params.items():
final_url = final_url.replace(f'{{{key}}}', str(value))
final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe=''))
if query_params:
query_string = '&'.join(f'{k}={v}' for k, v in query_params.items())
final_url = f'{final_url}?{query_string}'
final_url = f'{final_url}?{urlencode(query_params)}'
if operation.get('requestBody', {}).get('content'):
if params:
@@ -1364,7 +1434,7 @@ async def execute_tool_server(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as response:
if response.status >= 400:
text = await response.text()
@@ -1389,7 +1459,7 @@ async def execute_tool_server(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as response:
if response.status >= 400:
text = await response.text()
+5 -9
View File
@@ -3,17 +3,13 @@
import re
from urllib.parse import urlparse
# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts
# any user-ID without allowing path-traversal or query/fragment injection,
# and the ``$`` anchor rejects trailing path components.
from open_webui.env import PROFILE_IMAGE_ALLOWED_MIME_TYPES
_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$')
# Validates MIME type and structure of base64 data URIs. Only the prefix
# is checked — validating the full base64 payload would mean running a
# regex across megabytes of data on every Pydantic instantiation for zero
# security benefit (corrupt base64 simply renders a broken image, same as
# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts.
_SAFE_DATA_URI_RE = re.compile(r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE)
# Data-URI prefix validator derived from PROFILE_IMAGE_ALLOWED_MIME_TYPES.
_mime_suffixes = '|'.join(re.escape(t.split('/')[-1]) for t in sorted(PROFILE_IMAGE_ALLOWED_MIME_TYPES))
_SAFE_DATA_URI_RE = re.compile(rf'^data:image/({_mime_suffixes});base64,', re.IGNORECASE)
# Exact relative paths accepted as profile images. These are the only
# static-asset paths OWUI itself assigns; no prefix/wildcard matching is
+2 -1
View File
@@ -22,12 +22,13 @@ aiocache
aiofiles
starlette-compress==1.7.0
Brotli==1.2.0
brotlicffi==1.2.0.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.48
aiosqlite==0.21.0
asyncpg==0.30.0
psycopg[binary]==3.2.9
alembic==1.18.4
peewee==3.19.0
peewee-migrate==1.14.3
+3 -5
View File
@@ -19,13 +19,14 @@ aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.0
Brotli==1.2.0
brotlicffi==1.2.0.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
sqlalchemy==2.0.48
sqlalchemy[asyncio]==2.0.48
aiosqlite==0.21.0
asyncpg==0.30.0
psycopg[binary]==3.2.9
alembic==1.18.4
peewee==3.19.0
peewee-migrate==1.14.3
@@ -144,9 +145,6 @@ pytest-docker~=3.2.5
## LDAP
ldap3==2.9.1
## Firecrawl
firecrawl-py==4.18.0
## Trace
opentelemetry-api==1.40.0
opentelemetry-sdk==1.40.0
+2 -2
View File
@@ -24,7 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" (
IF "%PORT%"=="" SET PORT=8080
IF "%HOST%"=="" SET HOST=0.0.0.0
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS=*"
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS='*'"
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
@@ -47,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
:: Execute uvicorn
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips "%FORWARDED_ALLOW_IPS%" --workers %UVICORN_WORKERS% --ws auto
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips %FORWARDED_ALLOW_IPS% --workers %UVICORN_WORKERS% --ws auto
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto
+39 -5
View File
@@ -16,13 +16,26 @@ Based on a precedent of an unacceptable degree of spamming and unsolicited commu
Any reports or solicitations arriving from sources other than our designated GitHub repository will be dismissed without consideration. We’ve seen how external engagements can dilute and compromise the integrity of community-driven projects, and we’re not here to gamble with the security and privacy of our user community.
## Foreign CNAs and Vendor Disposition
When a report is filed via GitHub Security Advisories and the maintainers close it as out-of-scope per this policy, that closure is the **vendor's disposition** of the issue. A CVE Numbering Authority (CNA) that mints a CVE for such an issue without reflecting that vendor disposition in the resulting record is acting against vendor disposition.
We respond to such records by:
1. Filing a **REJECT** request with the CVE Program (with **DISPUTED** as fallback);
2. Cataloging the record publicly, naming the issuing CNA;
3. Refusing to provide vendor statements, version mappings, fix references, or any other coordination that would lend authority to the record;
4. Escalating repeated patterns from a single CNA to the CVE Program Root.
**Channel compliance does not entitle a CNA to override vendor disposition.** Reporters who escalate a closed-as-out-of-scope GHSA report to a third-party CNA after vendor disposition has been issued are likewise considered to have acted against vendor disposition, and will be permanently barred from future GHSA submissions.
## Reporting a Vulnerability
Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable.
We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**:
1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**.
1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. A vulnerability must cross at least one of the security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation). **These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them.**
2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
@@ -33,7 +46,7 @@ We appreciate the community's interest in identifying potential vulnerabilities.
> [!NOTE]
> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
>
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them)
> 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.)
> 3. What actions the attacker can now perform
> 4. What data/action becomes possible that should not be possible
@@ -105,7 +118,14 @@ Your remediation guidance can include, for example:
> - wrote comments with conflicting information
> - used illogical and conflicting arguments
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
12. **Self-Affecting Issues Are Not Vulnerabilities:** A vulnerability requires crossing a security boundary that affects **a party other than the reporter**. Crossing one of the five recognized security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) only against the reporter's own data, account, session, or environment is **not a vulnerability** - it is a bug, and belongs in the [Issue Tracker](https://github.com/open-webui/open-webui/issues), not in a security report.
> [!NOTE]
> This rule is about **who is harmed**, not about severity. A user modifying or deleting their own data, impairing their own session, observing their own configuration, or disabling security controls on their own account is out of scope under this rule, regardless of impact.
>
> If the same action also affects another user, the operator, the host system, or shared resources, identify that second party clearly in the PoC, and we want to hear about it.
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned from submitting reports.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
## Where to report the vulnerability
@@ -114,10 +134,24 @@ If you feel like you are not able to follow ALL outlined requirements for vulner
## Expected Response Timeframe
Due to the volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by a large number of invalid AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities.
Due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by an unbelievably high number of AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities.
**Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding.
## Report Handling
If you report a valid vulnerability that somebody else reported before you, we will close your report as a duplicate. The earliest filing is the one we will handle going forward, and we will not publish multiple advisories for the same vulnerability.
When multiple independent reporters describe the same vulnerability class but each demonstrates a **distinct and separate exploitation vector** — for example, the same missing authorization check reached through different endpoints — we will consolidate them into the earliest filing and credit every reporter who demonstrated a distinct path. Only one CVE will be issued for the consolidated advisory.
### Why duplicate reports don't receive credit
We credit only the earliest filer of a given vulnerability:
1. **The first report did the work.** By the time a later report arrives, triage and fix are already in motion. Later reports don't change the outcome or timeline; crediting them would misrepresent what moved the fix.
2. **Credit-for-duplicates incentivizes flooding.** If similar-but-later filings earn credit, the rational play is to skim open advisories and file variations. We already see this pressure — the first-filer rule is what limits it.
3. **Co-discovery is different from duplication.** Multiple reporters **are credited** on one advisory **when each contributes a _distinct_ finding** — different vector, different affected component, different sub-path the earlier filing does not cover. That is the consolidation rule above. Filing a duplicate of an existing report is not co-discovery.
## Confidential Disclosure
Vulnerability reports submitted through GitHub Security Advisories are **private and confidential**. Public disclosure of **ANY** details related to a submitted vulnerability report is **STRICTLY PROHIBITED** until the advisory has been **fully published** — not merely when a CVE ID has been assigned, but when the advisory itself is publicly visible.
@@ -157,4 +191,4 @@ For any other immediate concerns and questions, please create an issue in our [i
---
_Last updated on **2026-03-20**._
_Last updated on **2026-05-04**._
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.9.1",
"version": "0.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.9.1",
"version": "0.9.5",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@@ -2284,9 +2284,9 @@
"license": "Apache-2.0"
},
"node_modules/@mermaid-js/parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz",
"integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
"license": "MIT",
"dependencies": {
"langium": "^4.0.0"
@@ -3582,9 +3582,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.57.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"version": "2.59.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -5388,9 +5388,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -10948,14 +10948,14 @@
}
},
"node_modules/mermaid": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz",
"integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==",
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.0.1",
"@mermaid-js/parser": "^1.1.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
@@ -10989,9 +10989,9 @@
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -11847,9 +11847,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.9.1",
"version": "0.9.5",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
+4 -4
View File
@@ -26,14 +26,15 @@ dependencies = [
"aiocache==0.12.3",
"aiofiles==25.1.0",
"starlette-compress==1.7.0",
"Brotli==1.1.0",
"Brotli==1.2.0",
"brotlicffi==1.2.0.1",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
"sqlalchemy==2.0.48",
"sqlalchemy[asyncio]==2.0.48",
"aiosqlite==0.21.0",
"asyncpg==0.30.0",
"psycopg[binary]==3.2.9",
"alembic==1.18.4",
"peewee==3.19.0",
"peewee-migrate==1.14.3",
@@ -166,7 +167,6 @@ all = [
"oracledb==3.4.2",
"colbert-ai==0.2.22",
"firecrawl-py==4.18.0",
"azure-search-documents==11.6.0",
"unstructured==0.18.31",
]
+1 -1
View File
@@ -22,7 +22,7 @@ const packages = [
// static/pyodide/ so that the browser can install them offline via micropip.
// Packages already provided by the Pyodide distribution (click, platformdirs,
// typing_extensions, etc.) do NOT need to be listed here.
const pypiPackages = ['black', 'pathspec', 'mypy_extensions'];
const pypiPackages = ['black', 'pathspec', 'mypy_extensions', 'pytokens'];
import { loadPyodide } from 'pyodide';
import { setGlobalDispatcher, ProxyAgent } from 'undici';
+14
View File
@@ -71,6 +71,16 @@
metaThemeColorTag.setAttribute('content', '#171717');
}
const preloadHref = document.documentElement.classList.contains('dark')
? '/static/splash-dark.png'
: '/static/splash.png';
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'image';
preload.href = preloadHref;
preload.setAttribute('fetchpriority', 'high');
document.head.appendChild(preload);
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
if (localStorage.theme === 'system') {
if (e.matches) {
@@ -90,6 +100,8 @@
logo.id = 'logo';
logo.style =
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
logo.loading = 'eager';
logo.fetchPriority = 'high';
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
document.addEventListener('DOMContentLoaded', function () {
@@ -139,6 +151,8 @@
id="logo-her"
style="width: auto; height: 13rem"
src="/static/splash.png"
loading="eager"
fetchpriority="high"
class="animate-pulse-fast"
/>
+31 -1
View File
@@ -328,7 +328,7 @@ export const userSignOut = async () => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
method: 'GET',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
@@ -712,3 +712,33 @@ export const deleteAPIKey = async (token: string) => {
}
return res;
};
export const deleteOAuthSession = async (token: string, provider: string) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/auths/oauth/sessions/${encodeURIComponent(provider)}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
+39 -20
View File
@@ -301,34 +301,53 @@ export const getSharedChatList = async (token: string = '', page: number = 1, fi
};
export const getAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: 'application/x-ndjson',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
});
if (error) {
throw error;
if (!res.ok) {
const err = await res.json();
console.error(err);
throw err;
}
return res;
const reader = res.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
const decoder = new TextDecoder();
const chats: object[] = [];
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
chats.push(JSON.parse(trimmed));
}
}
}
// Process any remaining data in the buffer
const remaining = buffer.trim();
if (remaining) {
chats.push(JSON.parse(remaining));
}
return chats;
};
export const getChatListBySearchText = async (token: string, text: string, page: number = 1) => {
+1
View File
@@ -378,6 +378,7 @@ type RegisterOAuthClientForm = {
client_id: string;
client_name?: string;
client_secret?: string;
oauth_server_url?: string;
};
export const registerOAuthClient = async (
+83 -15
View File
@@ -4,6 +4,19 @@ import { getOpenAIModelsDirect } from './openai';
const TOOL_SERVER_FETCH_TIMEOUT = 10000;
// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*)
// and non-operation path-item fields (summary, description, servers, parameters).
const OPENAPI_HTTP_METHODS = new Set([
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace'
]);
// Every request sent from here is a petition. May it reach
// the one for whom it was intended, and return answered.
export const getModels = async (
@@ -159,6 +172,39 @@ export const getModels = async (
return models;
};
export const unloadModel = async (token: string, model: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models/unload`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({ model })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
type ChatCompletedForm = {
model: string;
messages: Record<string, unknown>[];
@@ -495,9 +541,15 @@ export const executeToolServer = async (
let error = null;
try {
// Find the matching operationId in the OpenAPI spec
// Find the matching operationId in the OpenAPI spec (only valid HTTP methods)
const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) =>
Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name)
Object.entries(methods as any).some(
([method, operation]: any) =>
OPENAPI_HTTP_METHODS.has(method) &&
operation &&
typeof operation === 'object' &&
operation.operationId === name
)
);
if (!matchingRoute) {
@@ -507,7 +559,11 @@ export const executeToolServer = async (
const [routePath, methods] = matchingRoute;
const methodEntry = Object.entries(methods as any).find(
([_, operation]: any) => operation.operationId === name
([method, operation]: any) =>
OPENAPI_HTTP_METHODS.has(method) &&
operation &&
typeof operation === 'object' &&
operation.operationId === name
);
if (!methodEntry) {
@@ -516,24 +572,36 @@ export const executeToolServer = async (
const [httpMethod, operation]: [string, any] = methodEntry;
// Merge path-level and operation-level parameters.
// Operation-level params override path-level params with the same (name, in).
const pathLevelParams: any[] = Array.isArray((methods as any).parameters)
? (methods as any).parameters
: [];
const opParams: any[] = Array.isArray(operation.parameters) ? operation.parameters : [];
const mergedParams = new Map();
for (const param of pathLevelParams) {
if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param);
}
for (const param of opParams) {
if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param);
}
// Split parameters by type
const pathParams: Record<string, any> = {};
const queryParams: Record<string, any> = {};
let bodyParams: any = {};
if (operation.parameters) {
operation.parameters.forEach((param: any) => {
const paramName = param?.name;
if (!paramName) return;
const paramIn = param?.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
} else if (paramIn === 'query') {
queryParams[paramName] = params[paramName];
}
for (const param of mergedParams.values()) {
const paramName = param?.name;
if (!paramName) continue;
const paramIn = param?.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
} else if (paramIn === 'query') {
queryParams[paramName] = params[paramName];
}
});
}
}
let finalUrl = `${url}${routePath}`;
+8 -2
View File
@@ -152,6 +152,9 @@ export const getBaseModels = async (token: string = '') => {
export const createNewModel = async (token: string, model: object) => {
let error = null;
const { id, base_model_id, name, meta, params, access_grants, is_active } = model as any;
const payload = { id, base_model_id, name, meta, params, access_grants, is_active };
const res = await fetch(`${WEBUI_API_BASE_URL}/models/create`, {
method: 'POST',
headers: {
@@ -159,7 +162,7 @@ export const createNewModel = async (token: string, model: object) => {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(model)
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@@ -251,6 +254,9 @@ export const toggleModelById = async (token: string, id: string) => {
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
const { base_model_id, name, meta, params, access_grants, is_active } = model as any;
const payload = { id, base_model_id, name, meta, params, access_grants, is_active };
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update`, {
method: 'POST',
headers: {
@@ -258,7 +264,7 @@ export const updateModelById = async (token: string, id: string, model: object)
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...model, id })
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
+19 -15
View File
@@ -36,9 +36,12 @@
let auth_type = 'bearer';
let connectionType = 'external';
let azure = false;
let provider = '';
$: azure =
(url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct ? true : false;
provider === 'azure' ||
((url.includes('azure.') || url.includes('cognitive.microsoft.com')) &&
!direct &&
provider === '');
let prefixId = '';
let enable = true;
@@ -98,7 +101,7 @@
key,
config: {
auth_type,
azure: azure,
...(provider ? { provider } : azure ? { azure: true } : {}),
api_version: apiVersion,
...(_headers ? { headers: _headers } : {})
}
@@ -186,7 +189,8 @@
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}),
...(provider ? { provider } : !ollama && azure ? { azure: true } : {}),
...(azure ? { api_version: apiVersion } : {}),
...(apiType ? { api_type: apiType } : {})
}
};
@@ -223,7 +227,7 @@
connectionType = connection.config?.connection_type ?? 'local';
} else {
connectionType = connection.config?.connection_type ?? 'external';
azure = connection.config?.azure ?? false;
provider = connection.config?.provider ?? (connection.config?.azure ? 'azure' : '');
apiVersion = connection.config?.api_version ?? '';
apiType = connection.config?.api_type ?? '';
}
@@ -491,22 +495,22 @@
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="prefix-id-input"
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider Type')}</label
>{$i18n.t('Provider')}</label
>
<div>
<button
on:click={() => {
azure = !azure;
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
{azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
</button>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
+26 -3
View File
@@ -60,6 +60,7 @@
let oauthClientId = '';
let oauthClientSecret = '';
let oauthServerUrl = '';
let enable = true;
let loading = false;
@@ -86,10 +87,17 @@
// client_id is the tool server ID (used as the internal lookup key for both flows).
// For static, client_secret signals the backend to use the static credential path.
// The actual OAuth client_id/secret come from the connection info at save time.
const formData: { url: string; client_id: string; client_secret?: string } = {
const formData: {
url: string;
client_id: string;
client_secret?: string;
oauth_server_url?: string;
} = {
url: url,
client_id: id,
...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {})
...(auth_type === 'oauth_2.1_static'
? { client_secret: oauthClientSecret, oauth_server_url: oauthServerUrl }
: {})
};
const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => {
@@ -336,7 +344,11 @@
description: description,
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}),
...(auth_type === 'oauth_2.1_static'
? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret }
? {
oauth_client_id: oauthClientId,
oauth_client_secret: oauthClientSecret,
oauth_server_url: oauthServerUrl
}
: {})
}
};
@@ -364,6 +376,7 @@
oauthClientInfo = null;
oauthClientId = '';
oauthClientSecret = '';
oauthServerUrl = '';
enable = true;
functionNameFilterList = '';
@@ -390,6 +403,7 @@
oauthClientInfo = connection.info?.oauth_client_info ?? null;
oauthClientId = connection.info?.oauth_client_id ?? '';
oauthClientSecret = connection.info?.oauth_client_secret ?? '';
oauthServerUrl = connection.info?.oauth_server_url ?? '';
enable = connection.config?.enable ?? true;
functionNameFilterList = connection.config?.function_name_filter_list ?? '';
@@ -730,6 +744,15 @@
placeholder={$i18n.t('Client Secret')}
required={false}
/>
<div class="flex flex-1 items-center">
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={oauthServerUrl}
placeholder={$i18n.t('OAuth Server URL')}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
@@ -194,10 +194,10 @@
<div
class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="text-lg font-medium px-0.5">
<div class="text-lg font-medium px-0.5 shrink-0">
{$i18n.t('Analytics')}
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap justify-end min-w-0">
{#if groups.length > 0}
<select
bind:value={selectedGroupId}
@@ -525,7 +525,7 @@
TTS_MODEL = 'tts-1';
} else if (e.target?.value === 'mistral') {
TTS_VOICE = '';
TTS_MODEL = 'mistral-tts-latest';
TTS_MODEL = 'voxtral-mini-tts-2603';
} else {
TTS_VOICE = '';
TTS_MODEL = '';
@@ -6,7 +6,6 @@
import Switch from '$lib/components/common/Switch.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
@@ -22,7 +21,6 @@
let showManageModal = false;
let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script>
<AddConnectionModal
@@ -35,7 +33,8 @@
config: config
}}
onDelete={() => {
showDeleteConfirmDialog = true;
onDelete();
showConfigModal = false;
}}
onSubmit={(connection) => {
url = connection.url;
@@ -44,14 +43,6 @@
}}
/>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
showConfigModal = false;
}}
/>
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
<div class="flex gap-1.5">
@@ -7,7 +7,6 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { connect } from 'socket.io-client';
@@ -21,16 +20,8 @@
export let config = {};
let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
}}
/>
<AddConnectionModal
edit
bind:show={showConfigModal}
@@ -40,7 +31,8 @@
config
}}
onDelete={() => {
showDeleteConfirmDialog = true;
onDelete();
showConfigModal = false;
}}
onSubmit={(connection) => {
url = connection.url;
@@ -184,6 +184,13 @@
toast.error($i18n.t('Mistral OCR API Key required.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'paddleocr_vl' &&
RAGConfig.PADDLEOCR_VL_BASE_URL === ''
) {
toast.error($i18n.t('PaddleOCR-vl API URL required.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru' &&
@@ -356,6 +363,7 @@
<option value="datalab_marker">{$i18n.t('Datalab Marker API')}</option>
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
<option value="mistral_ocr">{$i18n.t('Mistral OCR')}</option>
<option value="paddleocr_vl">{$i18n.t('PaddleOCR-vl')}</option>
<option value="mineru">{$i18n.t('MinerU')}</option>
</select>
</div>
@@ -657,6 +665,19 @@
bind:value={RAGConfig.MISTRAL_OCR_API_KEY}
/>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'paddleocr_vl'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter PaddleOCR-vl API Base URL')}
bind:value={RAGConfig.PADDLEOCR_VL_BASE_URL}
/>
<SensitiveInput
placeholder={$i18n.t('Enter PaddleOCR-vl API Token')}
bind:value={RAGConfig.PADDLEOCR_VL_TOKEN}
required={false}
/>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru'}
<!-- API Mode Selection -->
<div class="flex w-full mt-2">
@@ -1349,6 +1370,14 @@
/>
</Tooltip>
</div>
{#if RAGConfig.RAG_TEMPLATE && (RAGConfig.RAG_TEMPLATE.match(/\[context\]/g) || []).length + (RAGConfig.RAG_TEMPLATE.match(/\{\{CONTEXT\}\}/g) || []).length > 1}
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'This template contains multiple context placeholders ([context] or {{CONTEXT}}). Context will be injected at each occurrence.'
)}
</div>
{/if}
</div>
</div>
{/if}
@@ -22,7 +22,6 @@
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getToolServerConnections,
@@ -40,8 +39,6 @@
let terminalConnections = [];
let showAddTerminalModal = false;
let editTerminalIdx: number | null = null;
let showDeleteTerminalConfirm = false;
let deleteTerminalIdx: number | null = null;
const addConnectionHandler = async (server) => {
servers = [...servers, server];
@@ -135,23 +132,12 @@
}}
onDelete={() => {
if (editTerminalIdx !== null) {
deleteTerminalIdx = editTerminalIdx;
showDeleteTerminalConfirm = true;
removeTerminalConnection(editTerminalIdx);
editTerminalIdx = null;
}
}}
/>
<ConfirmDialog
bind:show={showDeleteTerminalConfirm}
on:confirm={() => {
if (deleteTerminalIdx !== null) {
removeTerminalConnection(deleteTerminalIdx);
deleteTerminalIdx = null;
}
}}
/>
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {

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