Compare commits

..

39 Commits

Author SHA1 Message Date
Aria d342a5060c Añadir backend/open_webui/env.py
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
2026-06-30 17:19:21 +00:00
Aria 437d0cf20f Eliminar backend/open_webui/env.py
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
Release / publish (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
2026-06-30 17:17:33 +00:00
Tim Baek 02dc3e689c Merge pull request #25590 from open-webui/dev
refac
2026-06-01 19:10:46 -07:00
Tim Baek b868da6bcd Merge pull request #25589 from open-webui/dev
refac
2026-06-01 19:05:05 -07:00
Tim Baek 90f4b4fcda Merge pull request #25524 from open-webui/dev
refac
2026-06-01 15:01:27 -07:00
Tim Baek b3e9d16b6f 0.9.6 (#25497)
* refac

* refac

* refac: reorganize scripts and ci workflows

* chore: remove unused cypress tests

* chore: remove unused legacy test suite

* refac: deprecate peewee migration layer

The Alembic init migration (7e5b5dc7342b) already creates the
equivalent schema. Peewee migrations are no longer needed for
any version >= 0.3.6.

* refac: remove dead peewee connection wrappers

* refac

* refac

* style: ruff format

* style: standardize os.environ.get to os.getenv

* refac: modernize imports, standardize type hints and docstrings

* refac: modernize type annotations (PEP 604 / PEP 585)

* refac

* feat: kb_exec

* refac

* refac

* refac

* refac

* upd:i18n: es-ES Translation update v0.9.5 (#24651)

es-ES Translation. Update v0.9.5

Added translation of new strings.

* feat: knowledge directory

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: German i18n translations (#24668)

* i18n: Update Swedish (sv-SE) translation — merge with latest dev (#24665)

Co-authored-by: Daniel Nylander <daniel@danielnylander.se>

* refac

* Fix translation for 'Authentication' in Danish (#24645)

* refac

* fix: korean i18n

* i18n: Update catalan translation.json (#24569)

* refac

* fix: validate folder_id ownership on chat create + folder-update endpoints (#24588)

POST /api/v1/chats/new and POST /api/v1/chats/{id}/folder accepted a
caller-supplied folder_id with no validation — neither ownership, nor
existence, nor UUID format. The row was persisted with the supplied
value verbatim, so the DB ended up with chat rows whose folder_id
referenced another user's folder, a non-existent UUID, or even a
non-UUID string.

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

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

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

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

* refac

* refac

* refac

* refac

* fix: enforce features.direct_tool_servers on chat-completion tool_servers (#24693)

* fix: enforce features.direct_tool_servers on chat-completion tool_servers

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

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

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

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

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

* chore: trim verbose comment on tool_servers permission check

---------

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

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: legacy peewee tables fk

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* fix: default optional env vars used with bash `,,` in start.sh (#24683)

start.sh runs with `set -euo pipefail`, but three call sites added in
070ab2650 (refac: reorganize scripts and ci workflows) reference
optional env vars via bash's `,,` lowercase expansion without any
default. Containers that don't set these vars — the default for every
deployment that isn't explicitly opting into Playwright / bundled
Ollama / CUDA — crash on startup with:

    start.sh: line 15: WEB_LOADER_ENGINE: unbound variable

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

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

Fix: normalise the three vars in a one-line preamble with `${VAR:=}`,
which assigns an empty default if unset. The downstream `${VAR,,}`
expressions stay exactly as Tim wrote them, preserving the file's
visual style and matching the existing `${VAR:-…}` idiom for "this
variable is optional".

* i18n: update Russian translations (#24728)

* Update SECURITY.md (#24726)

* fix: tag composite pk in migration (#24722)

* feat(ui): add emoji picker to rich text formatting toolbar (#24704)

* fix: wire workspace.skills into the sidebar + workspace-index gates (#24729)

Reported by bwgabrielsusai on #24719: granting a user only
`workspace.skills` doesn't show the Workspace menu, and visiting
`/workspace` directly bounces them to `/`.

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

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

Adding skills to both.

* refac

* refac

* chore: pyodide

* fix: get_image_base64_from_file_id

* refac

* i18n: update Irish translation (#24883)

* refac

* refactor: remove dead frontend API wrappers with no backend route (#24792)

These 19 exported wrappers are dead: each appears exactly once in the
codebase (its own definition), nothing imports or calls any of them, and
none has a corresponding backend route. They are leftovers from settings
that were consolidated server-side into /auths/admin/config,
/openai/config, /ollama/config and /api/config:

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused GET /evaluations/feedbacks/all endpoint (#24778)

GET /evaluations/feedbacks/all returned the entire feedback table in a
single response (flagged as a Medium OOM risk for admins in
open-webui#22206). Its only frontend wrapper, getAllFeedbacks in
src/lib/apis/evaluations/index.ts, is dead: nothing imports or calls it
anywhere in the codebase. The endpoint is a redundant view-only twin of
GET /evaluations/feedbacks/all/export, which is what the admin Feedbacks
UI actually uses.

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

Ref: open-webui#22206

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused POST /api/v1/utils/markdown endpoint (#24779)

POST /utils/markdown rendered a markdown string to HTML server-side. Its
only frontend wrapper, getHTMLFromMarkdown in src/lib/apis/utils/index.ts,
is dead: nothing imports or calls it, the route is hit by no other code
path, and the path string appears nowhere else in the repo (no direct
fetch, no test, no docs). Markdown is rendered client-side in the UI, so
this endpoint was redundant.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* refac

Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>

* refactor: remove unused DELETE /chats/{id}/tags/all endpoint (#24785)

The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac: audio

* fix: pass subscription_key and endpoint in bing.py CLI search_bing() call (#24768)

The __main__ block called search_bing() with 4 positional arguments, but
the function requires 5 (subscription_key, endpoint, locale, query,
count). Running `python -m open_webui.retrieval.web.bing` raised a
TypeError and, before failing, silently misrouted every argument. Read
the key/endpoint from environment variables, matching config.py defaults.

Closes #24765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: check destination calendar write access on event update (#24764)

update_event only verified write access on the event's source calendar.
CalendarEventUpdateForm accepts a new calendar_id which the model layer
applies unconditionally, so a user with write access to their own calendar
could move (inject) an event into any other user's calendar. Mirror the
destination check create_event already performs.

* refactor: remove dead generateFollowUps frontend wrapper (#24794)

generateFollowUps in src/lib/apis/index.ts is dead: it appears only at
its own definition, nothing imports or calls it, and it targets a
non-existent path (/api/v1/tasks/follow_ups/completions, plural) while
the real route is /tasks/follow_up/completions (singular). Follow-up
suggestions are generated server-side in the chat-completion middleware
and delivered over the chat:message:follow_ups websocket event, so this
wrapper was never on the live path.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: validate Playwright navigations and gate redirects in web loader (#24756)

SafePlaywrightURLLoader validated only the initially submitted URL and
then let the browser follow HTTP redirects and client-side navigations
without re-checking them, so a public URL could redirect into the
internal network (cloud metadata, RFC1918, loopback). Intercept
document-type requests, re-run validate_url on each, and apply the same
redirect policy as the requests loader (blocked unless
AIOHTTP_CLIENT_ALLOW_REDIRECTS). Sub-resource requests pass through
unchanged so page rendering performance is unaffected.

Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* enh: linkup

* fix: log expected fetch/transcript/tool-server failures as warnings (#24903)

* fix: emit [DONE] for AsyncGenerator pipe returns (#24763)

* fix: respect access_type in shared-chat file authorization branch (#24755)

has_access_to_file granted access whenever the file was attached to a
shared chat the user could read, ignoring the requested access_type. A
read-only shared-chat recipient therefore satisfied write and delete
checks and could delete or mutate the chat owner's attached file. Gate
the shared-chat branch on read access, matching the channels branch
directly above it.

Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* fix: mitigate DNS rebinding in web loader fetch paths (#24759)

validate_url() resolves DNS to check IPs but discards the result; the
HTTP client resolves again independently.  Between those two lookups an
attacker can swap the DNS record from a public IP to an internal one
(DNS rebinding).

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

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

Both paths respect ENABLE_RAG_LOCAL_WEB_FETCH (skip validation when on).

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

* fix: disable redirect following in OAuth picture fetch (SSRF) (#24809)

_process_picture_url validated the initial picture URL with validate_url()
but then aiohttp followed 3xx redirects without re-validating the target,
so a validate_url-passing public URL could 302 to an internal address and
the body was base64-stored in the user's profile_image_url. This is the
sixth call site of the CVE-2026-45401 redirect-bypass cohort; the other
five already pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS. Apply
the same.

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* Run transcode_audio_to_mp3 in a thread to avoid blocking (#24876)

This incorporates the transcoding implementation in #24145.

* refac

Co-Authored-By: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>

* refactor: remove unused GET /prompts/command/{command} endpoint (#24782)

The lookup-prompt-by-command endpoint's only frontend wrapper,
getPromptByCommand, is dead: nothing imports or calls it, the path is
referenced nowhere else, and the route handler has no internal caller
(slash-command resolution happens client-side from the loaded prompt
list). Removes the route handler, its section header, and the dead
wrapper.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac: kb sync

* refac

* refac

* refac

* refac

* refac

* refac: clean up Redis sentinel utilities and import grouping

* fix: resolve NameError for redis_sentinels in session_cleanup_lock

The variable was renamed to ws_sentinels but session_cleanup_lock
still referenced the old name, causing a startup crash.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update knowledge.py (#25053)

* refac

* refac

* fix(prompts): resolve undefined session variable in _get_access_grants and _to_prompt_model (#25129)

Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.

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

Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.

* fix: add knowledge_id access check in search_knowledge_files (BOLA) (#25113)

When called without attached model knowledge and given a caller-supplied knowledge_id, search_knowledge_files passed it straight to Knowledges.search_files_by_id, which does not enforce ownership on knowledge_id. An authenticated user who happened to know a target UUID could enumerate file metadata (filename, file id, KB id, KB name, updated_at) from any knowledge base, bypassing the AccessGrants permission model.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* fix(auth): use request.scope["path"] to prevent CVE-2026-48710 (BadHost) (#25123)

Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.

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

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

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

References:
  CVE-2026-48710 / BadHost
  https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/

* I18n/improve chinese translation (#25114)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* refac

* refac

* refac

* refac

* fix: harden model profile image against SVG stored XSS (#25060)

ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: gate chat-file links by caller access + repair insert_chat_files db arg (#25054)

insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.

Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.

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

Added missing translations and improved existing ones.

* Update main.py (#25271)

* fix(i18n): add missing Korean plural _one keys for selected/sources/minutes (#25228)

* fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219)

renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects
via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it
neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its
output; a .md file with a click X href "javascript:..." directive (or an
HTML-label payload) therefore executes script in the app origin when previewed.
The chat path was already safe because SVGPanZoom DOMPurifies before rendering;
file preview was not.

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

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

* fix: preserve parent_id on chat_message upsert (#25205)

* fix(ui): include reasoning_tags in user settings advanced params save handler (#25204)

* fix(ui): prevent long usernames from overflowing Edit User modal, User Preview modal, and sidebar (#25185)

Long usernames overflow the Edit User modal, User Preview modal header,
and the sidebar user area because the flex containers lack width
constraints.

- EditUserModal: add min-w-0 to the flex-1 container so the existing
  truncate class takes effect
- UserPreviewModal: add min-w-0 and truncate to the title container,
  flex-shrink-0 to the close button so it stays visible
- Sidebar: add truncate to the username display and flex-shrink-0 to
  the avatar container to prevent it from being squeezed

* fix(ui): add voice mode mute shortcut to keyboard shortcuts modal (#25193)

* fix(types): add missing markdown rendering settings to Settings type (#25198)

* i18n: complete Turkish (tr-TR) translation (#25210)

* fix: remove hardcoded WEBUI_SECRET_KEY fallback, require key explicitly (#25218)

The 't0p-s3cr3t' default was dead code on every supported startup path:
start.sh, start_windows.bat and `open-webui serve` all set or
auto-generate WEBUI_SECRET_KEY before the backend imports env.py. It was
only ever reachable by invoking uvicorn directly, which is unsupported
and unsafe (the app would then sign tokens/cookies with a public,
hardcoded key). It also keeps getting reported as a vulnerability because
it looks dangerous, even though it is unreachable in practice.

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

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

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

* fix: move bypass_system_prompt off query parameter onto request.state (#25156)

bypass_system_prompt is an internal flag used by utils/middleware.py and utils/chat.py to skip applying the model system prompt on recursive base-model calls, but it was still declared as a positional argument on the openai/ollama chat-completion route handlers, so FastAPI bound it from the query string. Move it to request.state so external clients cannot set it, matching how bypass_filter is handled.

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

Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(pl-PL): add missing polish translations (#25176)

* fix(models): gracefully handle legacy svg profile_image_url in ModelMeta validator (#25173)

The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.

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

Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
  ModelMeta.check_profile_image_url. Legacy entries are cleared to None
  with a warning log instead of raising — the /model/profile/image API
  endpoint already falls back to /static/favicon.png when the value is
  empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
  /static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
  future unexpected validation failure on a single record skips that
  model with an error log rather than aborting the entire list.

* refac

* fix: add null guards to channel Thread and PinnedMessagesModal components (#25209)

Thread.svelte: Add null check for messagesContainerElement in scrollToBottom()
to match the existing pattern in Channel.svelte. Prevents potential TypeError
when the DOM element is not yet bound during rapid thread switches.

PinnedMessagesModal.svelte: Move res.length check inside the if (res) block.
Previously, res.length was accessed unconditionally after a guarded block,
causing TypeError when the API call fails and the .catch() returns null.

* fix(settings): correct presence_penalty and repeat_penalty saving wrong values (#25183)

Both presence_penalty and repeat_penalty in the saveHandler read from
params.frequency_penalty instead of their own values due to a
copy-paste error. This causes users adjusting either parameter to
silently save the frequency_penalty value instead.

* fix(ui): enable custom parameters in user settings and admin model settings (#25200)

Add missing custom={true} prop to AdvancedParams in General.svelte and
ModelSettingsModal.svelte so the 'Add Custom Parameter' option appears
consistently across all advanced parameter surfaces.

Also forward custom_params in the General.svelte save handler so custom
parameters are persisted instead of silently dropped on save.

* i18n : (ms-MY) refine translation and standardise terminology (#25164)

* refac

* refac

* fix: decode terminal proxy path until stable to block multi-encoded traversal (#25157)

_sanitize_proxy_path decoded the proxy path once before the '..' check, so a double-encoded payload (%252e%252e) survived the check as %2e%2e and was then re-decoded into '..' by the upstream terminal server, defeating the traversal guard. Decode until stable so no encoded traversal sequence can reach the upstream. Single-encoded payloads were already rejected; this closes the double (and deeper) encoding bypass.

Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* chore: Update CHANGELOG.md (#24680)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* fix: use db instead of undefined session in chats model (#25455)

* fix(ui): use correct 'blur' event name instead of 'blur-sm' in window listeners (#25459)

* fix(ui): correct inverted high-contrast text colors for user message timestamp (#25461)

* refac

* refac

* refac

* refac

* fix: clear usage interval in finally so it cannot leak on send failure (#25478)

getChatEventEmitter starts a setInterval emitting a `usage` socket event
every second; clearInterval ran only after sendMessageSocket resolved, so
any throw/reject left the interval firing for the page lifetime. Each
failed send added another orphaned interval, inflating server-side usage
accounting and growing CPU/memory over a session.

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

Fixes #25465

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

* feat: cap profile image data URI size to bound model/avatar bloat (#25476)

* feat: cap profile image data URI size to bound model/avatar bloat

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

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

Fixes #25468

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

* fix: default PROFILE_IMAGE_MAX_DATA_URI_SIZE to None (no cap)

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

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

---------

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

* refac

* fix: don't hang terminal proxy when one forwarding pump exits first (#25479)

The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

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

Fixes #25464

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

* refac

Co-Authored-By: bannert <58707896+bannert1337@users.noreply.github.com>

* refac

Co-Authored-By: Boris Rybalkin <ribalkin@gmail.com>

* fix: cache path traversal via sibling-prefix bypass in serve_cache_file (#25086)

serve_cache_file gated the resolved path with file_path.startswith(os.path.abspath(CACHE_DIR)) without a trailing os.sep, so any path resolving to a sibling whose name starts with the cache-dir basename (e.g. cache_backup, cached_models) passed the prefix check. Authenticated users could read files from such siblings via /cache/../<sibling>/<file>. Appending os.sep to the prefix closes the bypass; deep traversal and absolute paths were already correctly blocked.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: persist outlet filter changes to message output (#24884)

* refac

* fix(images): pass content_type=None to r.json() to accept non-standard MIME types (#24838)

aiohttp's ClientResponse.json() validates the Content-Type header against
'application/json' by default and raises ContentTypeError for any other
value — including 'application/x-ndjson', which Ollama returns for its
OpenAI-compatible /v1/images/generations endpoint.

Pass content_type=None to skip this check while keeping all other parsing
behaviour unchanged.  The fix covers image generation (openai, gemini,
automatic1111 engines) and image editing (openai, gemini engines).

* fix(ui): guard JSON.parse(localStorage) calls with try/catch to prevent UI crashes (#25481)

* fix: don't crash on startup when stdout can't encode the banner (#25482)

The startup banner uses Unicode box-drawing characters. On a stdout that
can't encode them (Windows cp1252, or redirected/headless/pythonw output)
print() raises UnicodeEncodeError and aborts startup. This blocks running
open-webui serve headless on Windows.

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

Fixes #24965

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

* refac

* fix(knowledge): remove premature drag-and-drop upload toast (#25484)

* refac

* fix: don't block first-admin signup on stale ENABLE_SIGNUP (#24821)

Symptom
On a fresh install (zero users) the frontend shows the mandatory
"Create Admin Account" onboarding screen, but POST
/api/v1/auths/signup returns 403 ACCESS_PROHIBITED ("You do not have
permission to access this resource."). Wiping the database does not
help when the config layer is backed by Redis (the value survives in
the Redis/valkey volume), or when only the user table is cleared (the
config row survives in Postgres). The instance is then unrecoverable
through the UI.

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't revert replace/outlet content on chat save (#25485)

update_chat_by_id re-derived assistant content from `output` on every
save (serialize_output) so frontend edits to output items reflect in
content. But it ran unconditionally, so content set independently of
output — an __event_emitter__ {"type":"replace"} from an Action, or an
outlet filter footer — was reverted to the original output-derived text
on the next save. The reload reads chat.chat directly, so the change
vanished after navigating away (regression vs 0.9.2, which predates the
output mechanism).

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

Fixes #24585

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

* fix: reject collection names with unsafe characters in RAG ACL (#24982)

Open WebUI's collection ACL accepted any unknown name as a
legacy/ephemeral collection. In Milvus multi-tenancy mode that name
becomes the `resource_id` and is interpolated unescaped into a SQL-like
Milvus expression — `resource_id == '<name>'` — so a name like
  x' or resource_id != '' or resource_id == 'x
turns the filter into a tautology and returns every tenant's chunks
from the shared collection.

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

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

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

* refac

* refac: mirror native FC code_interpreter authz gates onto legacy XML-tag path (#24724)

The native function-calling tool resolver in utils/tools.py applies five
gates before exposing execute_code as a builtin tool: builtin-category
enable, ENABLE_CODE_INTERPRETER global config, model capability,
features.code_interpreter request flag, and the per-user
features.code_interpreter permission.

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

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

* fix: handle list-shape data in Firecrawl /search response (#24712)

Firecrawl /search returns either `{"data": [...]}` (flat list — v1, and
what frost19k reported on #23966) or `{"data": {"web": [...]}}` (v2,
current production). The parser only handled the dict shape:

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

On a list-shape response, `data.get('web')` raised AttributeError,
caught by the function's outer try/except, and `search_firecrawl`
silently returned []. Web search worked against v2 endpoints but is one
upstream-format-change away from failing closed again. Accept either.

* fix: bind prompt history/version ops to the authorized prompt (#25056)

The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* feat: add support for Valkey vector database (#24769)

* feat: add support for Valkey vector database

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

* feat: add CLIENT SETNAME to Valkey vector store connections

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

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

---------

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

* refac

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* refac

Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>

* refac

* refac

* refac

* Update Kagi API endpoint and request method (#25015)

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

* feat: add skills management to chat component (#25037)

- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.

* refac

* refac

* refac

Co-Authored-By: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* fix: apply RAG_EMBEDDING_QUERY_PREFIX to memory search queries (#24921)

The query_memory endpoint embeds the search query without the configured
RAG_EMBEDDING_QUERY_PREFIX, while every RAG retrieval path in
retrieval/utils.py correctly passes it. Instruction-tuned embedding
models (e.g. Qwen3-Embedding) produce poor results without the prefix,
causing memory search to return semantically unrelated results.

* refac

* fix: gate chat_completion channel: branch on channel access + message scoping (#24725)

* fix: gate chat_completion channel: branch on channel access + message scoping

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

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

Add the missing checks at the channel: branch:

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

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

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

* chore: trim verbose comment on channel: branch gate

---------

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

* refac

* feat(retrieval): add Perplexity attribution header (#24833)

Signed-off-by: James Liounis <james.liounis@perplexity.ai>

* refac

* refac

* Update CHANGELOG.md (#25453)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* fix: polyfill readable stream async iteration for Safari PDF extraction (#25473)

* refactor: move background tasks handler call to ensure consistent execution in chat response handlers (#24717)

* refac

* fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690)

The static credentials OAuth flow currently sets scope=None, relying on
the OAuth provider's default scopes. This breaks providers like GitHub
that default to minimal/public-only access when no scope is requested.

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

Fixes the regression introduced in 349ea4ea where all scope handling was
removed from the static flow.

* refac

* chore: bump

* chore

* chore: format

* Update CHANGELOG.md (#25491)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* refac

* refac

Co-Authored-By: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>

* chore: format

* refac

Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>

* fix: delete Qdrant points by ID so memory deletions don't orphan vectors (#25495)

The Qdrant backends implemented delete(ids=...) as a payload filter on
metadata.id, but points are stored with the item id as the Qdrant point id
(see _create_points), and not every point carries an id in its payload.
Memory points store only {created_at} in metadata (KB metadata embeddings
likewise), so deleting a single memory matched nothing and left an orphaned
vector that kept being injected into RAG context.

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

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

* i18n(fr-fr): update frensh translations (#24614)

Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: Tim Baek <tim@openwebui.com>

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL (#24587)

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL

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

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

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

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

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

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

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

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

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

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

---------

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

* refac

* chore: format

* refac

* refac

* refac

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: _00_ <131402327+rgaricano@users.noreply.github.com>
Co-authored-by: Taey <pythontogoplease@gmail.com>
Co-authored-by: Daniel Nylander <po@danielnylander.se>
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
Co-authored-by: Asbjørn Dyhrberg Thegler <asbjoern@dyhrbergthegler.dk>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>
Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>
Co-authored-by: mayamsin <58122670+mayamsin@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: G30 <50341825+silentoplayz@users.noreply.github.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Dara Adib <dara@quietapple.org>
Co-authored-by: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: maco <gosarmarcel7@gmail.com>
Co-authored-by: Sakıp Han Dursun <100518315+sakiphan@users.noreply.github.com>
Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Mateusz Hajder <6783135+mhajder@users.noreply.github.com>
Co-authored-by: Amir Subhi <amirsubhi@hotmail.com>
Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: bannert <58707896+bannert1337@users.noreply.github.com>
Co-authored-by: Boris Rybalkin <ribalkin@gmail.com>
Co-authored-by: HW <hwinkler@first-it-consulting.de>
Co-authored-by: sfwani <sfwani@users.noreply.github.com>
Co-authored-by: rileydes-improving <riley.desserre@improving.com>
Co-authored-by: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
Co-authored-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
Co-authored-by: russelg <russelg@users.noreply.github.com>
Co-authored-by: Mr. Meowgi <ovehbe@gmail.com>
Co-authored-by: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
Co-authored-by: Craig <66838006+TipKnuckle@users.noreply.github.com>
Co-authored-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: Chane Lu <2522992009@qq.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Justin Williams <justinjohnwilliams@gmail.com>
Co-authored-by: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>
Co-authored-by: hungryBird <pantazis.marina@gmail.com>
Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>
2026-06-01 14:56:48 -07:00
Tim Baek 3660bc00fd Merge pull request #24492 from open-webui/dev
0.9.5
2026-05-10 14:13:55 -04:00
Tim Baek f51d2b026f Merge pull request #24483 from open-webui/dev
0.9.4
2026-05-09 03:50:05 -04:00
Tim Baek 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
Tim Baek 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
Tim Baek 0a8a620fb6 Merge pull request #23925 from open-webui/dev
0.9.1
2026-04-21 06:45:24 -04:00
Tim Baek f31768e20e Merge pull request #23318 from open-webui/dev
0.9.0
2026-04-21 03:56:01 -04:00
Tim Baek 9bd84258d0 Merge pull request #23120 from open-webui/dev
0.8.12
2026-03-27 04:26:39 +04:00
Tim Baek 4d058a125b Merge pull request #23035 from open-webui/dev
0.8.11
2026-03-26 02:49:59 +04:00
Tim Baek e4e69a10ec Merge pull request #22469 from open-webui/dev
0.8.10
2026-03-09 04:09:43 +04:00
Tim Baek 6c159a97b7 Merge pull request #22390 from open-webui/dev
refac
2026-03-08 06:56:22 +04:00
Tim Baek 947dcd34bd Merge pull request #22385 from open-webui/dev
0.8.9
2026-03-08 06:47:14 +04:00
Tim Baek 79f0437980 Merge pull request #22168 from open-webui/dev
0.8.8
2026-03-03 03:32:58 +04:00
Tim Baek 6137f7cb7e Merge pull request #22121 from open-webui/dev
0.8.7
2026-03-02 05:14:08 +04:00
Tim Baek 9c9a18d6d4 Merge pull request #21971 from open-webui/dev
0.8.6
2026-03-02 01:03:55 +04:00
Tim Baek 1ac3dd4a89 Merge pull request #21773 from open-webui/dev
0.8.5
2026-02-23 13:26:21 +04:00
Tim Baek 2ed3055c42 Merge pull request #21618 from open-webui/dev
0.8.4
2026-02-23 11:58:08 +04:00
Tim Baek b8112d72b9 Merge pull request #21507 from open-webui/dev
refac
2026-02-17 01:25:39 -06:00
Tim Baek 4225791313 Merge pull request #21458 from open-webui/dev
0.8.3
2026-02-17 01:19:39 -06:00
Tim Baek 7c7fe44328 Merge pull request #21443 from open-webui/dev
0.8.2
2026-02-16 01:37:13 -06:00
Tim Baek 883f1dda0f Merge pull request #21346 from open-webui/dev
0.8.1
2026-02-13 18:04:11 -06:00
Tim Baek 7a7a25766c Merge pull request #21314 from open-webui/dev
0.8.0
2026-02-12 17:42:25 -06:00
Tim Baek 2b26355002 Merge pull request #20560 from open-webui/dev
0.7.2
2026-01-11 01:00:01 +04:00
Tim Baek f2a360cb87 Merge pull request #20523 from open-webui/dev
chore
2026-01-10 00:57:39 +04:00
Tim Baek f9b0534e0c Merge pull request #20522 from open-webui/dev
0.7.1
2026-01-10 00:44:36 +04:00
Tim Baek 6adde203cd Merge pull request #20394 from open-webui/dev
0.7
2026-01-09 22:51:34 +04:00
Tim Baek a7271532f8 0.6.43 (#20093)
* refac

* fix: python pip install dep issue

* Merge pull request #20085 from joaoback/patch-19

Update translation.json (pt-BR)

* fix/refac: stt default content type

* fix/refac: temp chat image handling

* fix/refac: image action button

* chore: bump

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2025-12-22 01:03:34 -05:00
Tim Baek d95f533214 Merge pull request #19729 from open-webui/dev
0.6.42
2025-12-21 16:08:58 -05:00
Tim Baek 6f1486ffd0 Merge pull request #19466 from open-webui/dev
0.6.41
2025-12-02 17:28:46 -05:00
Tim Baek 140605e660 Merge pull request #19462 from open-webui/dev
0.6.40
2025-11-25 06:01:33 -05:00
Tim Baek 9899293f05 Merge pull request #19448 from open-webui/dev
0.6.39
2025-11-25 05:31:34 -05:00
Tim Baek e3faec62c5 Merge pull request #19416 from open-webui/dev
0.6.38
2025-11-24 07:00:31 -05:00
Tim Baek fc05e0a6c5 Merge pull request #19405 from open-webui/dev
chore: format
2025-11-23 22:16:33 -05:00
Tim Baek fe6783c166 Merge pull request #19030 from open-webui/dev
0.6.37
2025-11-23 22:10:05 -05:00
152 changed files with 9018 additions and 11091 deletions
-1
View File
@@ -310,4 +310,3 @@ dist
cypress/videos
cypress/screenshots
.vscode/settings.json
.cptr
+25 -55
View File
@@ -27,84 +27,58 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
- 💾 **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.
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
- 📚 **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.
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
- 🔍 **Web 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.
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
- **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
- 🗄 **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
- **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## The Open WebUI Ecosystem 🌐
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
-**Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
@@ -248,10 +222,6 @@ This project contains code under multiple licenses. The current codebase include
If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Security 🛡️
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">
+1 -5
View File
@@ -11,7 +11,6 @@ import uvicorn
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
DEFAULT_SECRET_KEY_LENGTH = 24
def version_callback(value: bool) -> None:
@@ -38,11 +37,8 @@ def serve(
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
+1905 -822
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -768,11 +768,9 @@ if LICENSE_PUBLIC_KEY:
# WEBUI Identity
####################################
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI')
if WEBUI_NAME != 'Open WebUI':
WEBUI_NAME += ' (Open WebUI)'
WEBUI_NAME = os.getenv('WEBUI_NAME', 'Aria')
WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png'
WEBUI_FAVICON_URL = os.getenv('WEBUI_FAVICON_URL', '/static/favicon.png')
WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build')
TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '')
@@ -1066,4 +1064,4 @@ OTEL_METRICS_OTLP_SPAN_EXPORTER = os.getenv(
OTEL_LOGS_OTLP_SPAN_EXPORTER = os.getenv(
'OTEL_LOGS_OTLP_SPAN_EXPORTER', OTEL_OTLP_SPAN_EXPORTER
).lower() # grpc or http
).lower() # grpc or http
+265
View File
@@ -0,0 +1,265 @@
"""Database-backed configuration with environment variable defaults."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from functools import reduce
from typing import Any, Optional, Union
import redis
from open_webui.internal.db import Base, get_async_db, get_db
from open_webui.utils.redis import get_redis_connection
from sqlalchemy import JSON, Column, DateTime, Integer, func, select
log = logging.getLogger(__name__)
# ── Model ────────────────────────────────────────────────────────────────────
class ConfigTable(Base):
__tablename__ = 'config'
id = Column(Integer, primary_key=True)
data = Column(JSON, nullable=False)
version = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=True, onupdate=func.now())
# ── Blob ─────────────────────────────────────────────────────────────────────
class ConfigState:
"""In-memory mirror of the single-row config JSON blob."""
__slots__ = ('_data',)
def __init__(self) -> None:
self._data: dict[str, Any] = {}
@property
def snapshot(self) -> dict:
return self._data
def read(self, path: str) -> Any:
return reduce(
lambda n, k: n.get(k) if isinstance(n, dict) else None,
path.split('.'),
self._data,
)
def write(self, path: str, value: Any) -> None:
keys = path.split('.')
reduce(lambda d, k: d.setdefault(k, {}), keys[:-1], self._data)[keys[-1]] = value
def replace(self, data: dict) -> None:
self._data = data
def load(self) -> dict:
with get_db() as db:
row = db.query(ConfigTable).order_by(ConfigTable.id.desc()).first()
self._data = row.data if row else {'version': 0, 'ui': {}}
return self._data
def persist(self, data: dict | None = None) -> None:
if data is not None:
self._data = data
with get_db() as db:
row = db.query(ConfigTable).first()
if row is None:
db.add(ConfigTable(data=self._data, version=0))
else:
row.data, row.updated_at = self._data, datetime.now()
db.add(row)
db.commit()
async def persist_async(self, data: dict | None = None) -> None:
if data is not None:
self._data = data
async with get_async_db() as db:
result = await db.execute(select(ConfigTable).limit(1))
row = result.scalars().first()
if row is None:
db.add(ConfigTable(data=self._data, version=0))
else:
row.data, row.updated_at = self._data, datetime.now()
db.add(row)
await db.commit()
def clear(self) -> None:
with get_db() as db:
db.query(ConfigTable).delete()
db.commit()
async def clear_async(self) -> None:
from sqlalchemy import delete as sa_delete
async with get_async_db() as db:
await db.execute(sa_delete(ConfigTable))
await db.commit()
STATE = ConfigState()
# ── ConfigVar ──────────────────────────────────────────────────────────────────
_persist_enabled: bool = True
_oauth_persist_enabled: bool = False
_all_configs: list[ConfigVar] = []
def initialize(*, enable_persistent: bool = True, enable_oauth_persistent: bool = False) -> dict:
global _persist_enabled, _oauth_persist_enabled
_persist_enabled = enable_persistent
_oauth_persist_enabled = enable_oauth_persistent
return STATE.load()
class ConfigVar:
__slots__ = ('env_name', 'config_path', 'env_value', 'config_value', 'value')
def __init__(self, env_name: str, config_path: str, env_value: Any) -> None:
self.env_name = env_name
self.config_path = config_path
self.env_value = env_value
self.config_value = STATE.read(config_path)
if self.config_value is not None and _persist_enabled:
if config_path.startswith('oauth.') and not _oauth_persist_enabled:
log.info("Skipping DB value for '%s' (OAuth persistence disabled)", env_name)
self.value = env_value
else:
log.info("'%s' loaded from database", env_name)
self.value = self.config_value
else:
self.value = env_value
_all_configs.append(self)
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f'<ConfigVar {self.env_name}={self.value!r}>'
@property
def __dict__(self): # type: ignore[override]
raise TypeError(f"ConfigVar('{self.env_name}') cannot be cast to dict; use .value")
def __getattribute__(self, item: str):
if item == '__dict__':
raise TypeError('ConfigVar cannot be cast to dict; use .value')
return super().__getattribute__(item)
def refresh(self) -> None:
current = STATE.read(self.config_path)
if current is not None:
self.value = current
log.info('Refreshed %s%s', self.env_name, self.value)
def commit(self) -> None:
log.info("Persisting '%s'", self.env_name)
STATE.write(self.config_path, self.value)
self.config_value = self.value
STATE.persist()
async def commit_async(self) -> None:
log.info("Persisting '%s'", self.env_name)
STATE.write(self.config_path, self.value)
self.config_value = self.value
await STATE.persist_async()
# ── AppConfig ──────────────────────────────────────────────────────────
class AppConfig:
"""Attribute-style container for ConfigVars with optional Redis sync."""
def __init__(
self,
*,
redis_url: Optional[str] = None,
redis_sentinels: Optional[list] = None,
redis_cluster: bool = False,
redis_key_prefix: str = 'open-webui',
) -> None:
super().__setattr__('_entries', {})
super().__setattr__('_key_prefix', redis_key_prefix)
# If sentinels weren't explicitly provided, read from env.
if redis_sentinels is None:
from open_webui.env import REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
from open_webui.utils.redis import get_sentinels_from_env
redis_sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT)
rc: Union[redis.Redis, redis.cluster.RedisCluster, None] = None
if redis_url:
rc = get_redis_connection(redis_url, redis_sentinels or [], redis_cluster, decode_responses=True)
super().__setattr__('_rc', rc)
def __setattr__(self, name: str, value: Any) -> None:
entries: dict = super().__getattribute__('_entries')
if isinstance(value, ConfigVar):
entries[name] = value
return
entries[name].value = value
try:
asyncio.get_running_loop().create_task(self._write_async(name))
except RuntimeError:
entries[name].commit()
rc = super().__getattribute__('_rc')
if rc and _persist_enabled:
prefix = super().__getattribute__('_key_prefix')
try:
rc.set(f'{prefix}:config:{name}', json.dumps(entries[name].value))
except Exception as exc:
log.error("Redis write failed for '%s': %s", name, exc)
async def _write_async(self, name: str) -> None:
try:
await self._entries[name].commit_async()
except Exception as exc:
log.error("Async persist failed for '%s': %s", name, exc)
def __getattr__(self, name: str) -> Any:
entries = super().__getattribute__('_entries')
if name not in entries:
raise AttributeError(f"No config key '{name}'")
rc = super().__getattribute__('_rc')
if rc and _persist_enabled:
prefix = super().__getattribute__('_key_prefix')
try:
raw = rc.get(f'{prefix}:config:{name}')
if raw is not None:
decoded = json.loads(raw)
if entries[name].value != decoded:
entries[name].value = decoded
log.info("Updated '%s' from Redis", name)
except Exception as exc:
log.error("Redis read failed for '%s': %s", name, exc)
return entries[name].value
def _sync_to_redis(self) -> None:
rc = super().__getattribute__('_rc')
if not rc or not _persist_enabled:
return
prefix = super().__getattribute__('_key_prefix')
for name, s in super().__getattribute__('_entries').items():
try:
rc.set(f'{prefix}:config:{name}', json.dumps(s.value))
except Exception as exc:
log.error("Redis sync failed for '%s': %s", name, exc)
+934 -305
View File
File diff suppressed because it is too large Load Diff
@@ -1,580 +0,0 @@
"""reshape config to per key rows
Revision ID: 3ff2c63645b8
Revises: 461111b60977
Create Date: 2026-06-17 00:50:51.477073
"""
import json
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3ff2c63645b8'
down_revision: Union[str, None] = '461111b60977'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Maps every dot-notation blob path to its legacy env/config key name.
# Built from the legacy persistent config declarations in config.py.
BLOB_PATH_TO_KEY = {
"audio.stt.allowed_extensions": "AUDIO_STT_ALLOWED_EXTENSIONS",
"audio.stt.azure.api_key": "AUDIO_STT_AZURE_API_KEY",
"audio.stt.azure.base_url": "AUDIO_STT_AZURE_BASE_URL",
"audio.stt.azure.locales": "AUDIO_STT_AZURE_LOCALES",
"audio.stt.azure.max_speakers": "AUDIO_STT_AZURE_MAX_SPEAKERS",
"audio.stt.azure.region": "AUDIO_STT_AZURE_REGION",
"audio.stt.deepgram.api_key": "DEEPGRAM_API_KEY",
"audio.stt.engine": "AUDIO_STT_ENGINE",
"audio.stt.mistral.api_base_url": "AUDIO_STT_MISTRAL_API_BASE_URL",
"audio.stt.mistral.api_key": "AUDIO_STT_MISTRAL_API_KEY",
"audio.stt.mistral.use_chat_completions": "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS",
"audio.stt.model": "AUDIO_STT_MODEL",
"audio.stt.openai.api_base_url": "AUDIO_STT_OPENAI_API_BASE_URL",
"audio.stt.openai.api_key": "AUDIO_STT_OPENAI_API_KEY",
"audio.stt.supported_content_types": "AUDIO_STT_SUPPORTED_CONTENT_TYPES",
"audio.stt.whisper_model": "WHISPER_MODEL",
"audio.tts.api_key": "AUDIO_TTS_API_KEY",
"audio.tts.azure.speech_base_url": "AUDIO_TTS_AZURE_SPEECH_BASE_URL",
"audio.tts.azure.speech_output_format": "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT",
"audio.tts.azure.speech_region": "AUDIO_TTS_AZURE_SPEECH_REGION",
"audio.tts.engine": "AUDIO_TTS_ENGINE",
"audio.tts.mistral.api_base_url": "AUDIO_TTS_MISTRAL_API_BASE_URL",
"audio.tts.mistral.api_key": "AUDIO_TTS_MISTRAL_API_KEY",
"audio.tts.model": "AUDIO_TTS_MODEL",
"audio.tts.openai.api_base_url": "AUDIO_TTS_OPENAI_API_BASE_URL",
"audio.tts.openai.api_key": "AUDIO_TTS_OPENAI_API_KEY",
"audio.tts.openai.params": "AUDIO_TTS_OPENAI_PARAMS",
"audio.tts.split_on": "AUDIO_TTS_SPLIT_ON",
"audio.tts.voice": "AUDIO_TTS_VOICE",
"auth.admin.email": "ADMIN_EMAIL",
"auth.admin.show": "SHOW_ADMIN_DETAILS",
"auth.api_key.allowed_endpoints": "API_KEYS_ALLOWED_ENDPOINTS",
"auth.api_key.endpoint_restrictions": "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
"auth.enable_api_keys": "ENABLE_API_KEYS",
"auth.jwt_expiry": "JWT_EXPIRES_IN",
"automations.enable": "ENABLE_AUTOMATIONS",
"automations.max_count": "AUTOMATION_MAX_COUNT",
"automations.min_interval": "AUTOMATION_MIN_INTERVAL",
"calendar.enable": "ENABLE_CALENDAR",
"channels.enable": "ENABLE_CHANNELS",
"code_execution.enable": "ENABLE_CODE_EXECUTION",
"code_execution.engine": "CODE_EXECUTION_ENGINE",
"code_execution.jupyter.auth": "CODE_EXECUTION_JUPYTER_AUTH",
"code_execution.jupyter.auth_password": "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD",
"code_execution.jupyter.auth_token": "CODE_EXECUTION_JUPYTER_AUTH_TOKEN",
"code_execution.jupyter.timeout": "CODE_EXECUTION_JUPYTER_TIMEOUT",
"code_execution.jupyter.url": "CODE_EXECUTION_JUPYTER_URL",
"code_interpreter.enable": "ENABLE_CODE_INTERPRETER",
"code_interpreter.engine": "CODE_INTERPRETER_ENGINE",
"code_interpreter.jupyter.auth": "CODE_INTERPRETER_JUPYTER_AUTH",
"code_interpreter.jupyter.auth_password": "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
"code_interpreter.jupyter.auth_token": "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
"code_interpreter.jupyter.timeout": "CODE_INTERPRETER_JUPYTER_TIMEOUT",
"code_interpreter.jupyter.url": "CODE_INTERPRETER_JUPYTER_URL",
"code_interpreter.prompt_template": "CODE_INTERPRETER_PROMPT_TEMPLATE",
"direct.enable": "ENABLE_DIRECT_CONNECTIONS",
"evaluation.arena.enable": "ENABLE_EVALUATION_ARENA_MODELS",
"evaluation.arena.models": "EVALUATION_ARENA_MODELS",
"file.image_compression_height": "FILE_IMAGE_COMPRESSION_HEIGHT",
"file.image_compression_width": "FILE_IMAGE_COMPRESSION_WIDTH",
"folders.enable": "ENABLE_FOLDERS",
"folders.max_file_count": "FOLDER_MAX_FILE_COUNT",
"google_drive.api_key": "GOOGLE_DRIVE_API_KEY",
"google_drive.client_id": "GOOGLE_DRIVE_CLIENT_ID",
"google_drive.enable": "ENABLE_GOOGLE_DRIVE_INTEGRATION",
"image_generation.automatic1111.api_auth": "AUTOMATIC1111_API_AUTH",
"image_generation.automatic1111.api_params": "AUTOMATIC1111_PARAMS",
"image_generation.automatic1111.base_url": "AUTOMATIC1111_BASE_URL",
"image_generation.comfyui.api_key": "COMFYUI_API_KEY",
"image_generation.comfyui.base_url": "COMFYUI_BASE_URL",
"image_generation.comfyui.nodes": "COMFYUI_WORKFLOW_NODES",
"image_generation.comfyui.workflow": "COMFYUI_WORKFLOW",
"image_generation.enable": "ENABLE_IMAGE_GENERATION",
"image_generation.engine": "IMAGE_GENERATION_ENGINE",
"image_generation.gemini.api_base_url": "IMAGES_GEMINI_API_BASE_URL",
"image_generation.gemini.api_key": "IMAGES_GEMINI_API_KEY",
"image_generation.gemini.endpoint_method": "IMAGES_GEMINI_ENDPOINT_METHOD",
"image_generation.model": "IMAGE_GENERATION_MODEL",
"image_generation.openai.api_base_url": "IMAGES_OPENAI_API_BASE_URL",
"image_generation.openai.api_key": "IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_version": "IMAGES_OPENAI_API_VERSION",
"image_generation.openai.params": "IMAGES_OPENAI_API_PARAMS",
"image_generation.prompt.enable": "ENABLE_IMAGE_PROMPT_GENERATION",
"image_generation.size": "IMAGE_SIZE",
"image_generation.steps": "IMAGE_STEPS",
"images.edit.comfyui.api_key": "IMAGES_EDIT_COMFYUI_API_KEY",
"images.edit.comfyui.base_url": "IMAGES_EDIT_COMFYUI_BASE_URL",
"images.edit.comfyui.nodes": "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
"images.edit.comfyui.workflow": "IMAGES_EDIT_COMFYUI_WORKFLOW",
"images.edit.enable": "ENABLE_IMAGE_EDIT",
"images.edit.engine": "IMAGE_EDIT_ENGINE",
"images.edit.gemini.api_base_url": "IMAGES_EDIT_GEMINI_API_BASE_URL",
"images.edit.gemini.api_key": "IMAGES_EDIT_GEMINI_API_KEY",
"images.edit.model": "IMAGE_EDIT_MODEL",
"images.edit.openai.api_base_url": "IMAGES_EDIT_OPENAI_API_BASE_URL",
"images.edit.openai.api_key": "IMAGES_EDIT_OPENAI_API_KEY",
"images.edit.openai.api_version": "IMAGES_EDIT_OPENAI_API_VERSION",
"images.edit.size": "IMAGE_EDIT_SIZE",
"ldap.enable": "ENABLE_LDAP",
"ldap.group.enable_creation": "ENABLE_LDAP_GROUP_CREATION",
"ldap.group.enable_management": "ENABLE_LDAP_GROUP_MANAGEMENT",
"ldap.server.app_dn": "LDAP_APP_DN",
"ldap.server.app_password": "LDAP_APP_PASSWORD",
"ldap.server.attribute_for_groups": "LDAP_ATTRIBUTE_FOR_GROUPS",
"ldap.server.attribute_for_mail": "LDAP_ATTRIBUTE_FOR_MAIL",
"ldap.server.attribute_for_username": "LDAP_ATTRIBUTE_FOR_USERNAME",
"ldap.server.ca_cert_file": "LDAP_CA_CERT_FILE",
"ldap.server.ciphers": "LDAP_CIPHERS",
"ldap.server.host": "LDAP_SERVER_HOST",
"ldap.server.label": "LDAP_SERVER_LABEL",
"ldap.server.port": "LDAP_SERVER_PORT",
"ldap.server.search_filter": "LDAP_SEARCH_FILTER",
"ldap.server.use_tls": "LDAP_USE_TLS",
"ldap.server.users_dn": "LDAP_SEARCH_BASE",
"ldap.server.validate_cert": "LDAP_VALIDATE_CERT",
"memories.enable": "ENABLE_MEMORIES",
"models.base_models_cache": "ENABLE_BASE_MODELS_CACHE",
"models.default_metadata": "DEFAULT_MODEL_METADATA",
"models.default_params": "DEFAULT_MODEL_PARAMS",
"notes.enable": "ENABLE_NOTES",
# OAuth — direct paths
"oauth.admin_roles": "OAUTH_ADMIN_ROLES",
"oauth.allowed_domains": "OAUTH_ALLOWED_DOMAINS",
"oauth.allowed_roles": "OAUTH_ALLOWED_ROLES",
"oauth.audience": "OAUTH_AUDIENCE",
"oauth.auto_redirect": "OAUTH_AUTO_REDIRECT",
"oauth.blocked_groups": "OAUTH_BLOCKED_GROUPS",
"oauth.client.timeout": "OAUTH_CLIENT_TIMEOUT",
"oauth.enable_group_creation": "ENABLE_OAUTH_GROUP_CREATION",
"oauth.enable_group_mapping": "ENABLE_OAUTH_GROUP_MANAGEMENT",
"oauth.enable_role_mapping": "ENABLE_OAUTH_ROLE_MANAGEMENT",
"oauth.enable_signup": "ENABLE_OAUTH_SIGNUP",
"oauth.group_default_share": "OAUTH_GROUP_DEFAULT_SHARE",
"oauth.merge_accounts_by_email": "OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
"oauth.refresh_token_include_scope": "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE",
"oauth.roles_claim": "OAUTH_ROLES_CLAIM",
"oauth.update_email_on_login": "OAUTH_UPDATE_EMAIL_ON_LOGIN",
"oauth.update_name_on_login": "OAUTH_UPDATE_NAME_ON_LOGIN",
"oauth.update_picture_on_login": "OAUTH_UPDATE_PICTURE_ON_LOGIN",
# OAuth — generic provider paths
"oauth.client_id": "OAUTH_CLIENT_ID",
"oauth.client_secret": "OAUTH_CLIENT_SECRET",
"oauth.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
"oauth.email_claim": "OAUTH_EMAIL_CLAIM",
"oauth.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
"oauth.group_claim": "OAUTH_GROUP_CLAIM",
"oauth.picture_claim": "OAUTH_PICTURE_CLAIM",
"oauth.provider_name": "OAUTH_PROVIDER_NAME",
"oauth.provider_url": "OPENID_PROVIDER_URL",
"oauth.redirect_uri": "OPENID_REDIRECT_URI",
"oauth.scopes": "OAUTH_SCOPES",
"oauth.sub_claim": "OAUTH_SUB_CLAIM",
"oauth.timeout": "OAUTH_TIMEOUT",
"oauth.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
"oauth.username_claim": "OAUTH_USERNAME_CLAIM",
# OAuth — OIDC nested paths (flattened)
"oauth.oidc.avatar_claim": "OAUTH_PICTURE_CLAIM",
"oauth.oidc.client_id": "OAUTH_CLIENT_ID",
"oauth.oidc.client_secret": "OAUTH_CLIENT_SECRET",
"oauth.oidc.code_challenge_method": "OAUTH_CODE_CHALLENGE_METHOD",
"oauth.oidc.email_claim": "OAUTH_EMAIL_CLAIM",
"oauth.oidc.end_session_endpoint": "OPENID_END_SESSION_ENDPOINT",
"oauth.oidc.group_claim": "OAUTH_GROUP_CLAIM", # renamed from OAUTH_GROUPS_CLAIM
"oauth.oidc.oauth_timeout": "OAUTH_TIMEOUT",
"oauth.oidc.provider_name": "OAUTH_PROVIDER_NAME",
"oauth.oidc.provider_url": "OPENID_PROVIDER_URL",
"oauth.oidc.redirect_uri": "OPENID_REDIRECT_URI",
"oauth.oidc.scopes": "OAUTH_SCOPES",
"oauth.oidc.sub_claim": "OAUTH_SUB_CLAIM",
"oauth.oidc.token_endpoint_auth_method": "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
"oauth.oidc.username_claim": "OAUTH_USERNAME_CLAIM",
# OAuth — provider-specific
"oauth.feishu.client_id": "FEISHU_CLIENT_ID",
"oauth.feishu.client_secret": "FEISHU_CLIENT_SECRET",
"oauth.feishu.redirect_uri": "FEISHU_REDIRECT_URI",
"oauth.feishu.scope": "FEISHU_OAUTH_SCOPE",
"oauth.github.client_id": "GITHUB_CLIENT_ID",
"oauth.github.client_secret": "GITHUB_CLIENT_SECRET",
"oauth.github.redirect_uri": "GITHUB_CLIENT_REDIRECT_URI",
"oauth.github.scope": "GITHUB_CLIENT_SCOPE",
"oauth.google.client_id": "GOOGLE_CLIENT_ID",
"oauth.google.client_secret": "GOOGLE_CLIENT_SECRET",
"oauth.google.redirect_uri": "GOOGLE_REDIRECT_URI",
"oauth.google.scope": "GOOGLE_OAUTH_SCOPE",
"oauth.microsoft.client_id": "MICROSOFT_CLIENT_ID",
"oauth.microsoft.client_secret": "MICROSOFT_CLIENT_SECRET",
"oauth.microsoft.login_base_url": "MICROSOFT_CLIENT_LOGIN_BASE_URL",
"oauth.microsoft.picture_url": "MICROSOFT_CLIENT_PICTURE_URL",
"oauth.microsoft.redirect_uri": "MICROSOFT_REDIRECT_URI",
"oauth.microsoft.scope": "MICROSOFT_OAUTH_SCOPE",
"oauth.microsoft.tenant_id": "MICROSOFT_CLIENT_TENANT_ID",
# Ollama / OpenAI
"ollama.api_configs": "OLLAMA_API_CONFIGS",
"ollama.base_urls": "OLLAMA_BASE_URLS",
"ollama.enable": "ENABLE_OLLAMA_API",
"onedrive.enable": "ENABLE_ONEDRIVE_INTEGRATION",
"onedrive.sharepoint_tenant_id": "ONEDRIVE_SHAREPOINT_TENANT_ID",
"onedrive.sharepoint_url": "ONEDRIVE_SHAREPOINT_URL",
"openai.api_base_urls": "OPENAI_API_BASE_URLS",
"openai.api_configs": "OPENAI_API_CONFIGS",
"openai.api_keys": "OPENAI_API_KEYS",
"openai.enable": "ENABLE_OPENAI_API",
# RAG
"rag.content_extraction_engine": "CONTENT_EXTRACTION_ENGINE",
"rag.datalab_marker_use_llm": "DATALAB_MARKER_USE_LLM",
"rag.mistral_ocr_api_base_url": "MISTRAL_OCR_API_BASE_URL",
"rag.azure_openai.api_key": "RAG_AZURE_OPENAI_API_KEY",
"rag.azure_openai.api_version": "RAG_AZURE_OPENAI_API_VERSION",
"rag.azure_openai.base_url": "RAG_AZURE_OPENAI_BASE_URL",
"rag.bypass_embedding_and_retrieval": "BYPASS_EMBEDDING_AND_RETRIEVAL",
"rag.chunk_min_size_target": "CHUNK_MIN_SIZE_TARGET",
"rag.chunk_overlap": "CHUNK_OVERLAP",
"rag.chunk_size": "CHUNK_SIZE",
"rag.datalab_marker_additional_config": "DATALAB_MARKER_ADDITIONAL_CONFIG",
"rag.datalab_marker_api_base_url": "DATALAB_MARKER_API_BASE_URL",
"rag.datalab_marker_api_key": "DATALAB_MARKER_API_KEY",
"rag.datalab_marker_disable_image_extraction": "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION",
"rag.datalab_marker_force_ocr": "DATALAB_MARKER_FORCE_OCR",
"rag.datalab_marker_format_lines": "DATALAB_MARKER_FORMAT_LINES",
"rag.datalab_marker_output_format": "DATALAB_MARKER_OUTPUT_FORMAT",
"rag.datalab_marker_paginate": "DATALAB_MARKER_PAGINATE",
"rag.datalab_marker_skip_cache": "DATALAB_MARKER_SKIP_CACHE",
"rag.datalab_marker_strip_existing_ocr": "DATALAB_MARKER_STRIP_EXISTING_OCR",
"rag.docling_api_key": "DOCLING_API_KEY",
"rag.docling_params": "DOCLING_PARAMS",
"rag.docling_server_url": "DOCLING_SERVER_URL",
"rag.document_intelligence_endpoint": "DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_key": "DOCUMENT_INTELLIGENCE_KEY",
"rag.document_intelligence_model": "DOCUMENT_INTELLIGENCE_MODEL",
"rag.embedding_batch_size": "RAG_EMBEDDING_BATCH_SIZE",
"rag.embedding_concurrent_requests": "RAG_EMBEDDING_CONCURRENT_REQUESTS",
"rag.embedding_engine": "RAG_EMBEDDING_ENGINE",
"rag.embedding_model": "RAG_EMBEDDING_MODEL",
"rag.enable_async_embedding": "ENABLE_ASYNC_EMBEDDING",
"rag.enable_hybrid_search": "ENABLE_RAG_HYBRID_SEARCH",
"rag.enable_hybrid_search_enriched_texts": "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS",
"rag.enable_markdown_header_text_splitter": "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
"rag.external_document_loader_api_key": "EXTERNAL_DOCUMENT_LOADER_API_KEY",
"rag.external_document_loader_url": "EXTERNAL_DOCUMENT_LOADER_URL",
"rag.external_reranker_api_key": "RAG_EXTERNAL_RERANKER_API_KEY",
"rag.external_reranker_timeout": "RAG_EXTERNAL_RERANKER_TIMEOUT",
"rag.external_reranker_url": "RAG_EXTERNAL_RERANKER_URL",
"rag.file.allowed_extensions": "RAG_ALLOWED_FILE_EXTENSIONS",
"rag.file.max_count": "RAG_FILE_MAX_COUNT",
"rag.file.max_size": "RAG_FILE_MAX_SIZE",
"rag.full_context": "RAG_FULL_CONTEXT",
"rag.hybrid_bm25_weight": "RAG_HYBRID_BM25_WEIGHT",
"rag.mineru_api_key": "MINERU_API_KEY",
"rag.mineru_api_mode": "MINERU_API_MODE",
"rag.mineru_api_timeout": "MINERU_API_TIMEOUT",
"rag.mineru_api_url": "MINERU_API_URL",
"rag.mineru_file_extensions": "MINERU_FILE_EXTENSIONS",
"rag.mineru_params": "MINERU_PARAMS",
"rag.mistral_ocr_api_key": "MISTRAL_OCR_API_KEY",
"rag.ollama.key": "RAG_OLLAMA_API_KEY",
"rag.ollama.url": "RAG_OLLAMA_BASE_URL",
"rag.openai_api_base_url": "RAG_OPENAI_API_BASE_URL",
"rag.openai_api_key": "RAG_OPENAI_API_KEY",
"rag.paddleocr_vl_base_url": "PADDLEOCR_VL_BASE_URL",
"rag.paddleocr_vl_token": "PADDLEOCR_VL_TOKEN",
"rag.pdf_extract_images": "PDF_EXTRACT_IMAGES",
"rag.pdf_loader_mode": "PDF_LOADER_MODE",
"rag.relevance_threshold": "RAG_RELEVANCE_THRESHOLD",
"rag.reranking_batch_size": "RAG_RERANKING_BATCH_SIZE",
"rag.reranking_engine": "RAG_RERANKING_ENGINE",
"rag.reranking_model": "RAG_RERANKING_MODEL",
"rag.template": "RAG_TEMPLATE",
"rag.text_splitter": "RAG_TEXT_SPLITTER",
"rag.tika_server_url": "TIKA_SERVER_URL",
"rag.tiktoken_encoding_name": "TIKTOKEN_ENCODING_NAME",
"rag.top_k": "RAG_TOP_K",
"rag.top_k_reranker": "RAG_TOP_K_RERANKER",
# RAG — Web
"rag.web.fetch.max_content_length": "WEB_FETCH_MAX_CONTENT_LENGTH",
"rag.web.loader.concurrent_requests": "WEB_LOADER_CONCURRENT_REQUESTS",
"rag.web.loader.engine": "WEB_LOADER_ENGINE",
"rag.web.loader.external_web_loader_api_key": "EXTERNAL_WEB_LOADER_API_KEY",
"rag.web.loader.external_web_loader_url": "EXTERNAL_WEB_LOADER_URL",
"rag.web.loader.firecrawl_api_key": "FIRECRAWL_API_KEY",
"rag.web.loader.firecrawl_api_url": "FIRECRAWL_API_BASE_URL",
"rag.web.loader.firecrawl_timeout": "FIRECRAWL_TIMEOUT",
"rag.web.loader.playwright_timeout": "PLAYWRIGHT_TIMEOUT",
"rag.web.loader.playwright_ws_url": "PLAYWRIGHT_WS_URL",
"rag.web.loader.ssl_verification": "ENABLE_WEB_LOADER_SSL_VERIFICATION",
"rag.web.loader.timeout": "WEB_LOADER_TIMEOUT",
"rag.web.search.azure_ai_search_api_key": "AZURE_AI_SEARCH_API_KEY",
"rag.web.search.azure_ai_search_endpoint": "AZURE_AI_SEARCH_ENDPOINT",
"rag.web.search.azure_ai_search_index_name": "AZURE_AI_SEARCH_INDEX_NAME",
"rag.web.search.bing_search_v7_endpoint": "BING_SEARCH_V7_ENDPOINT",
"rag.web.search.bing_search_v7_subscription_key": "BING_SEARCH_V7_SUBSCRIPTION_KEY",
"rag.web.search.bocha_search_api_key": "BOCHA_SEARCH_API_KEY",
"rag.web.search.brave_search_api_key": "BRAVE_SEARCH_API_KEY",
"rag.web.search.brave_search_context_tokens": "BRAVE_SEARCH_CONTEXT_TOKENS",
"rag.web.search.bypass_embedding_and_retrieval": "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL",
"rag.web.search.bypass_web_loader": "BYPASS_WEB_SEARCH_WEB_LOADER",
"rag.web.search.concurrent_requests": "WEB_SEARCH_CONCURRENT_REQUESTS",
"rag.web.search.ddgs_backend": "DDGS_BACKEND",
"rag.web.search.domain.filter_list": "WEB_SEARCH_DOMAIN_FILTER_LIST",
"rag.web.search.enable": "ENABLE_WEB_SEARCH",
"rag.web.search.engine": "WEB_SEARCH_ENGINE",
"rag.web.search.exa_api_key": "EXA_API_KEY",
"rag.web.search.external_web_search_api_key": "EXTERNAL_WEB_SEARCH_API_KEY",
"rag.web.search.external_web_search_url": "EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.google_pse_api_key": "GOOGLE_PSE_API_KEY",
"rag.web.search.google_pse_engine_id": "GOOGLE_PSE_ENGINE_ID",
"rag.web.search.jina_api_base_url": "JINA_API_BASE_URL",
"rag.web.search.jina_api_key": "JINA_API_KEY",
"rag.web.search.kagi_search_api_key": "KAGI_SEARCH_API_KEY",
"rag.web.search.linkup_api_key": "LINKUP_API_KEY",
"rag.web.search.linkup_search_params": "LINKUP_SEARCH_PARAMS",
"rag.web.search.mojeek_search_api_key": "MOJEEK_SEARCH_API_KEY",
"rag.web.search.ollama_cloud_api_key": "OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
"rag.web.search.perplexity_api_key": "PERPLEXITY_API_KEY",
"rag.web.search.perplexity_model": "PERPLEXITY_MODEL",
"rag.web.search.perplexity_search_api_url": "PERPLEXITY_SEARCH_API_URL",
"rag.web.search.perplexity_search_context_usage": "PERPLEXITY_SEARCH_CONTEXT_USAGE",
"rag.web.search.result_count": "WEB_SEARCH_RESULT_COUNT",
"rag.web.search.searchapi_api_key": "SEARCHAPI_API_KEY",
"rag.web.search.searchapi_engine": "SEARCHAPI_ENGINE",
"rag.web.search.searxng_language": "SEARXNG_LANGUAGE",
"rag.web.search.searxng_query_url": "SEARXNG_QUERY_URL",
"rag.web.search.serpapi_api_key": "SERPAPI_API_KEY",
"rag.web.search.serpapi_engine": "SERPAPI_ENGINE",
"rag.web.search.serper_api_key": "SERPER_API_KEY",
"rag.web.search.serply_api_key": "SERPLY_API_KEY",
"rag.web.search.serpstack_api_key": "SERPSTACK_API_KEY",
"rag.web.search.serpstack_https": "SERPSTACK_HTTPS",
"rag.web.search.sougou_api_sid": "SOUGOU_API_SID",
"rag.web.search.sougou_api_sk": "SOUGOU_API_SK",
"rag.web.search.tavily_api_key": "TAVILY_API_KEY",
"rag.web.search.tavily_extract_depth": "TAVILY_EXTRACT_DEPTH",
"rag.web.search.trust_env": "WEB_SEARCH_TRUST_ENV",
"rag.web.search.yacy_password": "YACY_PASSWORD",
"rag.web.search.yacy_query_url": "YACY_QUERY_URL",
"rag.web.search.yacy_username": "YACY_USERNAME",
"rag.web.search.yandex_web_search_api_key": "YANDEX_WEB_SEARCH_API_KEY",
"rag.web.search.yandex_web_search_config": "YANDEX_WEB_SEARCH_CONFIG",
"rag.web.search.yandex_web_search_url": "YANDEX_WEB_SEARCH_URL",
"rag.web.search.youcom_api_key": "YOUCOM_API_KEY",
"rag.youtube_loader_language": "YOUTUBE_LOADER_LANGUAGE",
"rag.youtube_loader_proxy_url": "YOUTUBE_LOADER_PROXY_URL",
# Tasks
"task.autocomplete.enable": "ENABLE_AUTOCOMPLETE_GENERATION",
"task.autocomplete.input_max_length": "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
"task.autocomplete.prompt_template": "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE",
"task.follow_up.enable": "ENABLE_FOLLOW_UP_GENERATION",
"task.follow_up.prompt_template": "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE",
"task.image.prompt_template": "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
"task.model.default": "TASK_MODEL",
"task.model.external": "TASK_MODEL_EXTERNAL",
"task.query.prompt_template": "QUERY_GENERATION_PROMPT_TEMPLATE",
"task.query.retrieval.enable": "ENABLE_RETRIEVAL_QUERY_GENERATION",
"task.query.search.enable": "ENABLE_SEARCH_QUERY_GENERATION",
"task.tags.enable": "ENABLE_TAGS_GENERATION",
"task.tags.prompt_template": "TAGS_GENERATION_PROMPT_TEMPLATE",
"task.title.enable": "ENABLE_TITLE_GENERATION",
"task.title.prompt_template": "TITLE_GENERATION_PROMPT_TEMPLATE",
"task.tools.prompt_template": "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE",
"task.voice.prompt.enable": "ENABLE_VOICE_MODE_PROMPT",
"task.voice.prompt_template": "VOICE_MODE_PROMPT_TEMPLATE",
# Misc
"terminal_server.connections": "TERMINAL_SERVER_CONNECTIONS",
"tool_server.connections": "TOOL_SERVER_CONNECTIONS",
"ui.banners": "WEBUI_BANNERS",
"ui.default_group_id": "DEFAULT_GROUP_ID",
"ui.default_locale": "DEFAULT_LOCALE",
"ui.default_models": "DEFAULT_MODELS",
"ui.default_pinned_models": "DEFAULT_PINNED_MODELS",
"ui.default_user_role": "DEFAULT_USER_ROLE",
"ui.enable_community_sharing": "ENABLE_COMMUNITY_SHARING",
"ui.enable_login_form": "ENABLE_LOGIN_FORM",
"ui.enable_message_rating": "ENABLE_MESSAGE_RATING",
"ui.enable_password_change_form": "ENABLE_PASSWORD_CHANGE_FORM",
"ui.enable_signup": "ENABLE_SIGNUP",
"ui.enable_user_webhooks": "ENABLE_USER_WEBHOOKS",
"ui.model_order_list": "MODEL_ORDER_LIST",
"ui.pending_user_overlay_content": "PENDING_USER_OVERLAY_CONTENT",
"ui.pending_user_overlay_title": "PENDING_USER_OVERLAY_TITLE",
"ui.prompt_suggestions": "DEFAULT_PROMPT_SUGGESTIONS",
"ui.watermark": "RESPONSE_WATERMARK",
"user.permissions": "USER_PERMISSIONS",
"users.enable_status": "ENABLE_USER_STATUS",
"webhook_url": "WEBHOOK_URL",
"webui.url": "WEBUI_URL",
}
STORAGE_KEY_REWRITES = {
"oauth.refresh_token_include_scope": "oauth.refresh_token.include_scope",
"rag.openai_api_base_url": "rag.openai.api_base_url",
"rag.openai_api_key": "rag.openai.api_key",
"rag.ollama.url": "rag.ollama.base_url",
"rag.ollama.key": "rag.ollama.api_key",
"oauth.oidc.avatar_claim": "oauth.picture_claim",
"oauth.oidc.client_id": "oauth.client_id",
"oauth.oidc.client_secret": "oauth.client_secret",
"oauth.oidc.code_challenge_method": "oauth.code_challenge_method",
"oauth.oidc.email_claim": "oauth.email_claim",
"oauth.oidc.end_session_endpoint": "oauth.end_session_endpoint",
"oauth.oidc.group_claim": "oauth.group_claim",
"oauth.oidc.oauth_timeout": "oauth.timeout",
"oauth.oidc.provider_name": "oauth.provider_name",
"oauth.oidc.provider_url": "oauth.provider_url",
"oauth.oidc.redirect_uri": "oauth.redirect_uri",
"oauth.oidc.scopes": "oauth.scopes",
"oauth.oidc.sub_claim": "oauth.sub_claim",
"oauth.oidc.token_endpoint_auth_method": "oauth.token_endpoint_auth_method",
"oauth.oidc.username_claim": "oauth.username_claim",
}
LEGACY_KEY_TO_STORAGE_KEY = {
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path)
for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
}
def _walk_blob(data: dict, prefix: str = '') -> dict:
"""Recursively walk a nested dict, yielding (dot.path, value) for leaf nodes."""
result = {}
for key, value in data.items():
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
if isinstance(value, dict):
result.update(_walk_blob(value, path))
else:
result[path] = value
return result
def upgrade() -> None:
"""Reshape config from single-row JSON blob to per-key rows."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
has_old_config = {'id', 'data'}.issubset(config_columns)
has_new_config = {'key', 'value'}.issubset(config_columns)
# Ad-hoc table reference for reading the old schema
old_config = sa.table(
'config',
sa.column('id', sa.Integer),
sa.column('data', sa.JSON),
)
# 1. Read existing blob
blob_data = {}
if has_old_config:
try:
result = conn.execute(
sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1)
)
row = result.fetchone()
if row and row[0]:
raw = row[0]
blob_data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
pass # Table might be partially migrated or empty
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
if has_old_config:
if 'config_old' in table_names:
op.drop_table('config_old')
op.rename_table('config', 'config_old')
# 3. Create new per-key table
new_config = (
sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON()),
sa.column('updated_at', sa.BigInteger),
)
if has_new_config
else op.create_table(
'config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# 4. Flatten blob and insert per-key rows
if blob_data:
flat = _walk_blob(blob_data)
# Keep stable dot-notation paths as the database keys.
# Known legacy env-style keys are rewritten to their dotted keys; unknown
# keys are still copied so custom/future config is not silently lost.
rows = {}
for blob_path, value in flat.items():
if blob_path in BLOB_PATH_TO_KEY:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
else:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
if storage_key not in rows:
rows[storage_key] = value
# Batch insert via SQLAlchemy table reference
if rows:
now = int(time.time())
op.bulk_insert(
new_config,
[
{'key': k, 'value': v, 'updated_at': now}
for k, v in rows.items()
],
)
def downgrade() -> None:
"""Restore preserved old single-row config table when available."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
if 'config_old' in table_names:
if 'config' in table_names:
op.drop_table('config')
op.rename_table('config_old', 'config')
return
config_columns = {column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
has_per_key_config = {'key', 'value'}.issubset(config_columns)
blob_data = {}
if has_per_key_config:
config = sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON),
)
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
blob_data[key] = json.loads(value) if isinstance(value, str) else value
op.drop_table('config')
if 'config' in table_names and not has_per_key_config:
return
old_config = op.create_table(
'config',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
if blob_data:
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])
-8
View File
@@ -6,7 +6,6 @@ import logging
import uuid
from typing import Optional
import bcrypt
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
@@ -16,11 +15,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
# Pre-computed hash verified on signin paths that lack a real credential
# (unknown user, inactive account) so response timing cannot reveal
# whether an account exists (CWE-208).
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
class Auth(Base): # credential ↔ user linkage
"""Maps a user ID to an email/password pair with an active flag."""
@@ -148,13 +142,11 @@ class AuthsTable:
log.info('authenticate_user: %s', email)
resolved = await Users.get_user_by_email(email, db=db)
if not resolved:
verify_password(PLACEHOLDER_HASH)
return
# load the credential row and verify the password hash
async with get_async_db_context(db) as session:
credential = await session.get(Auth, resolved.id)
if not credential or not credential.active:
verify_password(PLACEHOLDER_HASH)
return
if not verify_password(credential.password):
return
@@ -3,8 +3,6 @@ import time
import uuid
from typing import Any, Optional
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
@@ -442,44 +440,6 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()}
async def get_unique_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
stmt = select(
ChatMessage.model_id,
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
return {
row.model_id: {
'unique_users': row.unique_users,
'unique_chats': row.unique_chats,
}
for row in result.all()
}
async def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
+1 -80
View File
@@ -309,7 +309,6 @@ class ChatTable:
'folder_id': form_data.folder_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
'last_read_at': int(time.time()),
}
)
@@ -446,6 +445,7 @@ class ChatTable:
clean_title = self._clean_null_bytes(title)
chat_item.title = clean_title
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
chat_item.updated_at = int(time.time())
await session.commit()
await session.refresh(chat_item)
return ChatModel.model_validate(chat_item)
@@ -748,7 +748,6 @@ class ChatTable:
chat = await session.get(Chat, id)
chat.pinned = not chat.pinned
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
@@ -762,7 +761,6 @@ class ChatTable:
chat.archived = not chat.archived
chat.folder_id = None
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
@@ -831,17 +829,6 @@ class ChatTable:
for chat in all_chats
]
async def count_archived_chats_by_user_id(
self,
user_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(
select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
)
return result.scalar() or 0
async def get_shared_chat_list_by_user_id(
self,
user_id: str,
@@ -970,20 +957,6 @@ class ChatTable:
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
async def get_chat_metas_by_chat_ids(
self,
chat_ids: list[str],
include_archived: bool = False,
db: AsyncSession | None = None,
) -> list[dict]:
async with get_async_db_context(db) as session:
stmt = select(Chat.meta).filter(Chat.id.in_(chat_ids))
if not include_archived:
stmt = stmt.filter_by(archived=False)
result = await session.execute(stmt)
return [meta for meta in result.scalars().all() if isinstance(meta, dict)]
# retrieve conversation
async def get_chat_by_id(
self,
@@ -1380,42 +1353,6 @@ class ChatTable:
for chat in all_chats
]
async def get_all_chats_by_folder_id(
self,
folder_id: str,
skip: int = 0,
limit: int = 60,
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.order_by(Chat.updated_at.desc(), Chat.id)
)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
all_chats = result.all()
return [
{
'id': chat[0],
'title': chat[1],
'user_id': chat[2],
'updated_at': chat[3],
'created_at': chat[4],
'last_read_at': chat[5],
}
for chat in all_chats
]
async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
@@ -1440,7 +1377,6 @@ class ChatTable:
chat = await session.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
chat.pinned = False
await session.commit()
await session.refresh(chat)
@@ -1585,21 +1521,6 @@ class ChatTable:
log.info(f"Count of chats for folder '{folder_id}': {count}")
return count
async def count_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as session:
result = await session.execute(
select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
)
count = result.scalar()
log.info(f"Count of chats for folders '{folder_ids}': {count}")
return count
async def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: AsyncSession | None = None
) -> bool:
-177
View File
@@ -1,177 +0,0 @@
"""Database-backed configuration with per-key storage.
Replaces the old single-row JSON blob machinery with a simple per-key model
mirroring cptr's Config.
Each config key is stored as its own row: key TEXT PK, value JSON.
Reads are direct DB lookups. Writes are explicit awaited upserts that raise on
failure (no more fire-and-forget create_task).
"""
from __future__ import annotations
import logging
import time
from typing import Any, ClassVar
from open_webui.internal.db import Base, get_async_db
from sqlalchemy import JSON, BigInteger, Column, Text, select
log = logging.getLogger(__name__)
# ── Model ────────────────────────────────────────────────────────────────────
class Config(Base):
"""Per-key config storage. Each row is one config key."""
__tablename__ = 'config'
key = Column(Text, primary_key=True)
value = Column(JSON, nullable=False)
updated_at = Column(BigInteger, nullable=True)
DEFAULTS: ClassVar[dict[str, Any]] = {}
PERSISTENT_ENABLED: ClassVar[bool] = True
OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False
# ── Class methods ────────────────────────────────────────
@classmethod
def configure(
cls,
*,
defaults: dict[str, Any] | None = None,
enable_persistent: bool = True,
enable_oauth_persistent: bool = False,
) -> None:
cls.DEFAULTS = defaults or {}
cls.PERSISTENT_ENABLED = enable_persistent
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
@classmethod
def default_value(cls, key: str, default: Any = None) -> Any:
return cls.DEFAULTS.get(key, default)
@classmethod
def persistent_enabled_for(cls, key: str) -> bool:
if not cls.PERSISTENT_ENABLED:
return False
if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED:
return False
return True
@staticmethod
async def get(key: str, default: Any = None) -> Any:
"""Get a config value by key. Returns default if not set."""
if not Config.persistent_enabled_for(key):
return Config.default_value(key, default)
async with get_async_db() as db:
row = await db.get(Config, key)
return row.value if row else Config.default_value(key, default)
@staticmethod
async def get_many(*keys: str) -> dict:
"""Get multiple config values. Returns {key: value} for keys that exist."""
disabled_values = {
key: Config.default_value(key)
for key in keys
if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS
}
enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)}
if not enabled_keys:
return disabled_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.in_(enabled_keys)))
values = {row.key: row.value for row in result.scalars().all()}
return {
key: values.get(key, Config.default_value(key))
for key in keys
if key in values or key in Config.DEFAULTS or key in disabled_values
}
@staticmethod
async def get_namespace(namespace: str) -> dict:
"""Get all config keys under a dotted namespace."""
default_values = {
key: value
for key, value in Config.DEFAULTS.items()
if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key)
}
if not Config.PERSISTENT_ENABLED:
return default_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%')))
values = {row.key: row.value for row in result.scalars().all()}
values.update(default_values)
return values
@staticmethod
async def get_all() -> dict:
"""Get all config as {key: value}."""
if not Config.PERSISTENT_ENABLED:
return dict(Config.DEFAULTS)
async with get_async_db() as db:
result = await db.execute(select(Config))
values = {row.key: row.value for row in result.scalars().all()}
if not Config.OAUTH_PERSISTENT_ENABLED:
values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')})
return values
@staticmethod
async def upsert(updates: dict) -> None:
"""Upsert multiple config key-value pairs. Raises on failure."""
async with get_async_db() as db:
now = int(time.time())
for key, value in updates.items():
existing = await db.get(Config, key)
if existing:
existing.value = value
existing.updated_at = now
else:
db.add(Config(key=key, value=value, updated_at=now))
await db.commit()
@staticmethod
async def delete(key: str) -> bool:
"""Delete a config key. Returns True if it existed."""
async with get_async_db() as db:
row = await db.get(Config, key)
if row:
await db.delete(row)
await db.commit()
return True
return False
@staticmethod
async def clear() -> None:
"""Delete all config rows."""
from sqlalchemy import delete as sa_delete
async with get_async_db() as db:
await db.execute(sa_delete(Config))
await db.commit()
@staticmethod
async def seed_defaults(defaults: dict) -> None:
"""Insert keys that don't yet exist in the DB.
Called at startup to ensure all known config keys have values.
Existing DB values take precedence over defaults.
"""
async with get_async_db() as db:
result = await db.execute(select(Config.key))
existing_keys = {row[0] for row in result.all()}
now = int(time.time())
new_count = 0
for key, value in defaults.items():
if key not in existing_keys:
db.add(Config(key=key, value=value, updated_at=now))
existing_keys.add(key)
new_count += 1
if new_count:
await db.commit()
log.info('Seeded %d new config defaults', new_count)
+4 -56
View File
@@ -133,12 +133,6 @@ class ModelHistoryEntry(BaseModel):
lost: int
class ModelHistoryCounts(BaseModel):
date: str
won: int = 0
lost: int = 0
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
@@ -222,19 +216,12 @@ class FeedbackTable:
) -> FeedbackListResponse:
async with get_async_db_context(db) as db:
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
count_stmt = (
select(func.count(Feedback.id))
.select_from(Feedback)
.join(User, Feedback.user_id == User.id)
)
if filter:
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
model_id_filter = Feedback.data['model_id'].as_string() == model_id
stmt = stmt.filter(model_id_filter)
count_stmt = count_stmt.filter(model_id_filter)
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
order_by = filter.get('order_by')
direction = filter.get('direction')
@@ -263,9 +250,9 @@ class FeedbackTable:
else:
stmt = stmt.order_by(Feedback.created_at.desc())
# Count before pagination without wrapping the ordered item query.
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
@@ -388,45 +375,6 @@ class FeedbackTable:
return result
async def get_model_feedback_counts_by_day(
self,
model_id: str,
start_date: Optional[int] = None,
db: Optional[AsyncSession] = None,
) -> list[ModelHistoryCounts]:
"""Get aggregated feedback counts per day for a model, preserving all matching days."""
from collections import defaultdict
from datetime import datetime
async with get_async_db_context(db) as db:
stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
if start_date is not None:
stmt = stmt.filter(Feedback.created_at >= start_date)
result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
rows = result.all()
daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
for created_at, data in rows:
if not data:
continue
rating_str = str(data.get('rating', ''))
if rating_str not in ('1', '-1'):
continue
date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
if rating_str == '1':
daily_counts[date_str]['won'] += 1
else:
daily_counts[date_str]['lost'] += 1
return [
ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
for date_str, counts in sorted(daily_counts.items())
]
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
-12
View File
@@ -201,18 +201,6 @@ class FilesTable:
result = await db.execute(select(File))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def count_files_by_user_id(
self,
user_id: str | None = None,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as db:
stmt = select(func.count(File.id))
if user_id:
stmt = stmt.filter_by(user_id=user_id)
result = await db.execute(stmt)
return result.scalar() or 0
async def check_access_by_user_id(self, id, user_id, permission='write', db: AsyncSession | None = None) -> bool:
file = await self.get_file_by_id(id, db=db)
if not file:
+1 -84
View File
@@ -6,7 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -62,20 +62,6 @@ class FolderNameIdResponse(BaseModel):
updated_at: int
class SharedFolderResponse(BaseModel):
id: str
name: str
parent_id: Optional[str] = None
user_id: str
owner_name: Optional[str] = None
permission: str = 'read'
access_grants: list = []
is_expanded: bool = False
meta: Optional[dict] = None
created_at: int
updated_at: int
####################
# Forms
####################
@@ -144,56 +130,6 @@ class FolderTable:
except Exception:
return None
async def get_folder_by_id(
self, id: str, db: Optional[AsyncSession] = None
) -> Optional[FolderModel]:
"""Fetch folder by ID only (no user_id filter). Used for shared access."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id))
folder = result.scalars().first()
if not folder:
return None
return FolderModel.model_validate(folder)
except Exception:
return None
async def get_shared_folder_ids_for_user(
self, user_id: str, user_group_ids: set[str],
db: Optional[AsyncSession] = None
) -> dict[str, str]:
"""
Returns {folder_id: highest_permission} for all folders shared with user.
Checks direct user grants, group grants, and public (user:*) grants.
"""
from open_webui.models.access_grants import AccessGrant
async with get_async_db_context(db) as db:
conditions = [
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'),
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id),
]
if user_group_ids:
conditions.append(
and_(AccessGrant.principal_type == 'group',
AccessGrant.principal_id.in_(user_group_ids))
)
result = await db.execute(
select(AccessGrant).filter(
AccessGrant.resource_type == 'folder',
or_(*conditions),
)
)
grants = result.scalars().all()
# Build {folder_id: highest_permission} ('write' > 'read')
folder_perms = {}
for g in grants:
existing = folder_perms.get(g.resource_id)
if existing != 'write':
folder_perms[g.resource_id] = g.permission
return folder_perms
async def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[list[FolderModel]]:
@@ -252,25 +188,6 @@ class FolderTable:
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
async def get_folder_ids_by_id_and_user_id_in_subtree(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return []
folder_ids = [folder.id]
folders = [FolderModel.model_validate(folder)]
while folders:
current_folder = folders.pop()
children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db)
folder_ids.extend(child.id for child in children)
folders.extend(children)
return folder_ids
async def update_folder_parent_id_by_id_and_user_id(
self,
id: str,
-10
View File
@@ -201,15 +201,5 @@ class SharedChatsTable:
except Exception:
return False
async def delete_all_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chats created by a user."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(user_id=user_id))
await db.commit()
return True
except Exception:
return False
SharedChats = SharedChatsTable()
+59 -89
View File
@@ -39,7 +39,6 @@ from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.models.notes import Notes
from open_webui.models.config import Config
from open_webui.models.users import UserModel
from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
@@ -64,84 +63,65 @@ def is_youtube_url(url: str) -> bool:
return re.match(youtube_regex, url) is not None
LOADER_CONFIG_KEYS = {
'youtube_language': 'rag.youtube_loader_language',
'youtube_proxy_url': 'rag.youtube_loader_proxy_url',
'web_loader_ssl_verification': 'rag.web.loader.ssl_verification',
'web_loader_concurrent_requests': 'rag.web.loader.concurrent_requests',
'web_search_trust_env': 'rag.web.search.trust_env',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache',
'DATALAB_MARKER_FORCE_OCR': 'rag.datalab_marker_force_ocr',
'DATALAB_MARKER_PAGINATE': 'rag.datalab_marker_paginate',
'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr',
'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': 'rag.datalab_marker_disable_image_extraction',
'DATALAB_MARKER_FORMAT_LINES': 'rag.datalab_marker_format_lines',
'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm',
'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format',
'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url',
'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key',
'TIKA_SERVER_URL': 'rag.tika_server_url',
'DOCLING_SERVER_URL': 'rag.docling_server_url',
'DOCLING_API_KEY': 'rag.docling_api_key',
'DOCLING_PARAMS': 'rag.docling_params',
'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images',
'PDF_LOADER_MODE': 'rag.pdf_loader_mode',
'DOCUMENT_INTELLIGENCE_ENDPOINT': 'rag.document_intelligence_endpoint',
'DOCUMENT_INTELLIGENCE_KEY': 'rag.document_intelligence_key',
'DOCUMENT_INTELLIGENCE_MODEL': 'rag.document_intelligence_model',
'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url',
'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key',
'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url',
'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token',
'MINERU_API_MODE': 'rag.mineru_api_mode',
'MINERU_API_URL': 'rag.mineru_api_url',
'MINERU_API_KEY': 'rag.mineru_api_key',
'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout',
'MINERU_PARAMS': 'rag.mineru_params',
'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions',
}
async def get_loader_config():
values = await Config.get_many(*LOADER_CONFIG_KEYS.values())
return {name: values.get(key) for name, key in LOADER_CONFIG_KEYS.items()}
def get_loader(request, url: str, config: dict):
def get_loader(request, url: str):
if is_youtube_url(url):
return YoutubeLoader(
url,
language=config.get('youtube_language'),
proxy_url=config.get('youtube_proxy_url'),
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
)
else:
return get_web_loader(
url,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
return get_web_loader(
url,
verify_ssl=config.get('web_loader_ssl_verification'),
requests_per_second=config.get('web_loader_concurrent_requests'),
trust_env=config.get('web_search_trust_env'),
)
def build_loader_from_config(request, config: dict):
def build_loader_from_config(request):
"""Build a Loader instance with the admin's configured extraction engine settings."""
from open_webui.retrieval.loaders.main import Loader
loader_config = {
key: config.get(key)
for key in LOADER_CONFIG_KEYS
if key.isupper()
}
config = request.app.state.config
return Loader(
engine=loader_config['CONTENT_EXTRACTION_ENGINE'],
**{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'},
engine=config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=config.DOCLING_SERVER_URL,
DOCLING_API_KEY=config.DOCLING_API_KEY,
DOCLING_PARAMS=config.DOCLING_PARAMS,
PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=config.PDF_LOADER_MODE,
DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL,
PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN,
MINERU_API_MODE=config.MINERU_API_MODE,
MINERU_API_URL=config.MINERU_API_URL,
MINERU_API_KEY=config.MINERU_API_KEY,
MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT,
MINERU_PARAMS=config.MINERU_PARAMS,
MINERU_FILE_EXTENSIONS=config.MINERU_FILE_EXTENSIONS,
)
def _extract_text_from_binary_response(request, response: requests.Response, url: str, loader_config: dict) -> tuple[str, list]:
def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]:
"""Download response body to a temp file and extract text using the Loader pipeline."""
import mimetypes
import tempfile
@@ -170,7 +150,7 @@ def _extract_text_from_binary_response(request, response: requests.Response, url
tmp_path = tmp.name
try:
loader = build_loader_from_config(request, loader_config)
loader = build_loader_from_config(request)
docs = loader.load(filename, content_type, tmp_path)
for doc in docs:
doc.metadata['source'] = url
@@ -190,11 +170,9 @@ def _is_text_content_type(content_type: str) -> bool:
return not ct # empty / missing → assume HTML
async def get_content_from_url(request, url: str) -> str:
def get_content_from_url(request, url: str) -> str:
from open_webui.retrieval.web.utils import validate_url
loader_config = await get_loader_config()
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
@@ -205,7 +183,7 @@ async def get_content_from_url(request, url: str) -> str:
# when allow_redirects=False, causing the binary-content path to run
# and produce empty docs → HTTP 400.
if is_youtube_url(url):
loader = get_loader(request, url, loader_config)
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
@@ -227,14 +205,14 @@ async def get_content_from_url(request, url: str) -> str:
if response is None or _is_text_content_type(content_type):
if response is not None:
response.close()
loader = get_loader(request, url, loader_config)
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
# Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract
try:
return _extract_text_from_binary_response(request, response, url, loader_config)
return _extract_text_from_binary_response(request, response, url)
finally:
response.close()
@@ -561,15 +539,8 @@ async def query_collection(
embedding_function,
k: int,
) -> dict:
config = await Config.get_many(
'rag.enable_hybrid_search',
'rag.top_k_reranker',
'rag.relevance_threshold',
'rag.hybrid_bm25_weight',
'rag.enable_hybrid_search_enriched_texts',
)
# When request is provided, try hybrid search + reranking if enabled
if request and config.get('rag.enable_hybrid_search'):
if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
try:
reranking_function = (
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents))
@@ -582,10 +553,10 @@ async def query_collection(
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=config.get('rag.top_k_reranker'),
r=config.get('rag.relevance_threshold'),
hybrid_bm25_weight=config.get('rag.hybrid_bm25_weight'),
enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'),
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
)
except Exception as e:
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
@@ -1194,7 +1165,6 @@ async def get_sources_from_items(
):
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
extracted_collections = []
query_results = []
@@ -1274,14 +1244,14 @@ async def get_sources_from_items(
}
elif item.get('type') == 'url':
content, docs = await get_content_from_url(request, item.get('url'))
content, docs = get_content_from_url(request, item.get('url'))
if docs:
query_result = {
'documents': [[content]],
'metadatas': [[{'url': item.get('url'), 'name': item.get('url')}]],
}
elif item.get('type') == 'file':
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
if item.get('file', {}).get('data', {}).get('content', ''):
# Manual Full Mode Toggle
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
@@ -1353,7 +1323,7 @@ async def get_sources_from_items(
permission='read',
)
):
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
if knowledge_base and (
user.role == 'admin'
or knowledge_base.user_id == user.id
@@ -27,15 +27,9 @@ from open_webui.retrieval.vector.main import (
from open_webui.retrieval.vector.utils import process_metadata
from pymilvus import Collection, DataType, FieldSchema, connections
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
log = logging.getLogger(__name__)
# Milvus caps stored text length (here the chunk lives under the JSON `data`
# field). Clamp long chunks before insert so one oversized chunk can't fail the
# whole batch and leave the file with zero embeddings.
MILVUS_TEXT_MAX_LENGTH = 65535
class MilvusClient(VectorDBBase):
def __init__(self):
@@ -276,34 +270,18 @@ class MilvusClient(VectorDBBase):
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
data = []
for item in items:
text = item['text'] or ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(
f'Milvus: truncating text id={item["id"]} '
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars'
)
text = text[:MILVUS_TEXT_MAX_LENGTH]
data.append(
return self.client.insert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=[
{
'id': item['id'],
'vector': item['vector'],
'data': {'text': text},
'data': {'text': item['text']},
'metadata': process_metadata(item['metadata']),
}
)
try:
return self.client.insert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=data,
)
except MilvusException as e:
log.error(
f'Milvus insert failed for {self.collection_prefix}_{collection_name} '
f'({len(items)} items): {e}'
)
raise
for item in items
],
)
def upsert(self, collection_name: str, items: list[VectorItem]):
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
@@ -320,34 +298,18 @@ class MilvusClient(VectorDBBase):
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
data = []
for item in items:
text = item['text'] or ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(
f'Milvus: truncating text id={item["id"]} '
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars'
)
text = text[:MILVUS_TEXT_MAX_LENGTH]
data.append(
return self.client.upsert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=[
{
'id': item['id'],
'vector': item['vector'],
'data': {'text': text},
'data': {'text': item['text']},
'metadata': process_metadata(item['metadata']),
}
)
try:
return self.client.upsert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=data,
)
except MilvusException as e:
log.error(
f'Milvus upsert failed for {self.collection_prefix}_{collection_name} '
f'({len(items)} items): {e}'
)
raise
for item in items
],
)
def delete(
self,
@@ -31,15 +31,10 @@ from pymilvus import (
connections,
utility,
)
from pymilvus.exceptions import MilvusException
log = logging.getLogger(__name__)
RESOURCE_ID_FIELD = 'resource_id'
# Milvus VARCHAR hard cap for the `text` field (see _create_shared_collection).
# Chunks longer than this are truncated before insert so one oversized chunk
# can't fail the whole batch (and leave the file with zero embeddings).
MILVUS_TEXT_MAX_LENGTH = 65535
# Milvus expressions are SQL-like strings with no parameterized-query API;
# values get interpolated into single-quoted literals. Reject anything that
@@ -174,34 +169,17 @@ class MilvusClient(VectorDBBase):
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = []
for item in items:
text = item['text'] or ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(
f'Milvus: truncating text id={item["id"]} '
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars '
f'(collection={mt_collection}, resource_id={resource_id})'
)
text = text[:MILVUS_TEXT_MAX_LENGTH]
entities.append(
{
'id': item['id'],
'vector': item['vector'],
'text': text,
'metadata': item['metadata'],
RESOURCE_ID_FIELD: resource_id,
}
)
try:
collection.insert(entities)
except MilvusException as e:
log.error(
f'Milvus insert failed (collection={mt_collection}, '
f'resource_id={resource_id}, items={len(entities)}): {e}'
)
raise
entities = [
{
'id': item['id'],
'vector': item['vector'],
'text': item['text'],
'metadata': item['metadata'],
RESOURCE_ID_FIELD: resource_id,
}
for item in items
]
collection.insert(entities)
def search(
self,
+2 -2
View File
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
import validators
from open_webui.retrieval.web.utils import resolve_hostname
from open_webui.utils.misc import is_host_allowed
from open_webui.utils.misc import is_string_allowed
from pydantic import BaseModel
@@ -32,7 +32,7 @@ def get_filtered_results(results, filter_list):
except Exception:
pass
if is_host_allowed(hostnames, filter_list):
if is_string_allowed(hostnames, filter_list):
filtered_results.append(result)
continue
@@ -38,7 +38,9 @@ def search_perplexity(
"""
api_key = str(api_key)
# Handle ConfigVar object
if hasattr(api_key, '__str__'):
api_key = str(api_key)
try:
url = 'https://api.perplexity.ai/chat/completions'
@@ -29,8 +29,12 @@ def search_perplexity_search(
"""
api_key = str(api_key)
api_url = str(api_url)
# Handle ConfigVar object
if hasattr(api_key, '__str__'):
api_key = str(api_key)
if hasattr(api_url, '__str__'):
api_url = str(api_url)
try:
url = api_url
+23 -42
View File
@@ -21,6 +21,7 @@ from typing import (
import aiohttp
import aiohttp.resolver
import certifi
import requests
import urllib3.connection
import urllib3.connectionpool
import validators
@@ -45,16 +46,11 @@ from open_webui.config import (
WEB_LOADER_TIMEOUT,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
USER_AGENT,
)
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, USER_AGENT
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
from open_webui.utils.misc import is_host_allowed
from open_webui.utils.misc import is_string_allowed
log = logging.getLogger(__name__)
@@ -92,9 +88,7 @@ def validate_url(url: Union[str, Sequence[str]]):
# Blocklist check using unified filtering logic
if WEB_FETCH_FILTER_LIST:
# Match on the parsed hostname, not the full URL: a path component would
# otherwise let any URL slip past a hostname-based block/allow entry.
if not is_host_allowed(parsed_url.hostname, WEB_FETCH_FILTER_LIST):
if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):
log.warning(f'URL blocked by filter list: {url}')
raise ValueError(ERROR_MESSAGES.INVALID_URL)
@@ -203,19 +197,6 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
return results
def get_ssrf_safe_session() -> aiohttp.ClientSession:
"""A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver,
defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must
not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed:
``async with get_ssrf_safe_session() as session: ...``.
"""
return aiohttp.ClientSession(
connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()),
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
trust_env=True,
)
def extract_metadata(soup, url):
metadata = {'source': url}
if title := soup.find('title'):
@@ -776,13 +757,13 @@ def get_web_loader(
'trust_env': trust_env,
}
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
if WEB_LOADER_ENGINE.value == '' or WEB_LOADER_ENGINE.value == 'safe_web':
WebLoaderClass = SafeWebBaseLoader
request_kwargs = {}
if WEB_LOADER_TIMEOUT:
if WEB_LOADER_TIMEOUT.value:
try:
timeout_value = float(WEB_LOADER_TIMEOUT)
timeout_value = float(WEB_LOADER_TIMEOUT.value)
except ValueError:
timeout_value = None
@@ -792,31 +773,31 @@ def get_web_loader(
if request_kwargs:
web_loader_args['requests_kwargs'] = request_kwargs
if WEB_LOADER_ENGINE == 'playwright':
if WEB_LOADER_ENGINE.value == 'playwright':
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
if PLAYWRIGHT_WS_URL:
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT.value
if PLAYWRIGHT_WS_URL.value:
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL.value
if WEB_LOADER_ENGINE == 'firecrawl':
if WEB_LOADER_ENGINE.value == 'firecrawl':
WebLoaderClass = SafeFireCrawlLoader
web_loader_args['api_key'] = FIRECRAWL_API_KEY
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
if FIRECRAWL_TIMEOUT:
web_loader_args['api_key'] = FIRECRAWL_API_KEY.value
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL.value
if FIRECRAWL_TIMEOUT.value:
try:
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT.value)
except ValueError:
pass
if WEB_LOADER_ENGINE == 'tavily':
if WEB_LOADER_ENGINE.value == 'tavily':
WebLoaderClass = SafeTavilyLoader
web_loader_args['api_key'] = TAVILY_API_KEY
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
web_loader_args['api_key'] = TAVILY_API_KEY.value
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH.value
if WEB_LOADER_ENGINE == 'external':
if WEB_LOADER_ENGINE.value == 'external':
WebLoaderClass = ExternalWebLoader
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL.value
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY.value
if WebLoaderClass:
web_loader = WebLoaderClass(**web_loader_args)
@@ -830,6 +811,6 @@ def get_web_loader(
return web_loader
else:
raise ValueError(
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. '
"Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'."
)
+30 -37
View File
@@ -28,8 +28,6 @@ router = APIRouter()
class ModelAnalyticsEntry(BaseModel):
model_id: str
count: int
unique_users: int = 0
unique_chats: int = 0
class ModelAnalyticsResponse(BaseModel):
@@ -67,16 +65,8 @@ async def get_model_analytics(
counts = await ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
unique_counts = await ChatMessages.get_unique_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
ModelAnalyticsEntry(
model_id=model_id,
count=count,
unique_users=unique_counts.get(model_id, {}).get('unique_users', 0),
unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0),
)
ModelAnalyticsEntry(model_id=model_id, count=count)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
]
return ModelAnalyticsResponse(models=models)
@@ -377,12 +367,6 @@ async def get_model_overview(
):
"""Get model overview with feedback history and chat tags."""
# Calculate start date for history
now = datetime.now()
start_dt = None
if days > 0:
start_dt = now - timedelta(days=days)
# Get chat IDs that used this model
chat_ids = await ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
@@ -393,18 +377,31 @@ async def get_model_overview(
db=db,
)
history_rows = await Feedbacks.get_model_feedback_counts_by_day(
model_id=model_id,
start_date=int(start_dt.timestamp()) if start_dt else None,
db=db,
)
history_counts = {
entry.date: {
'won': entry.won,
'lost': entry.lost,
}
for entry in history_rows
}
# Get feedback history per day
history_counts: dict[str, dict] = defaultdict(lambda: {'won': 0, 'lost': 0})
# Calculate start date for history
now = datetime.now()
start_dt = None
if days > 0:
start_dt = now - timedelta(days=days)
for chat_id in chat_ids:
feedbacks = await Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
for fb in feedbacks:
if fb.data and 'rating' in fb.data:
rating = fb.data['rating']
fb_date = datetime.fromtimestamp(fb.created_at)
# Filter by date range
if start_dt and fb_date < start_dt:
continue
date_str = fb_date.strftime('%Y-%m-%d')
if rating == 1:
history_counts[date_str]['won'] += 1
elif rating == -1:
history_counts[date_str]['lost'] += 1
# Fill in missing days
history = []
@@ -433,14 +430,10 @@ async def get_model_overview(
# Get chat tags
tag_counts: dict[str, int] = defaultdict(int)
if chat_ids:
chat_metas = await Chats.get_chat_metas_by_chat_ids(
chat_ids,
include_archived=True,
db=db,
)
for meta in chat_metas:
for tag in meta.get('tags', []):
for chat_id in chat_ids:
chat = await Chats.get_chat_by_id(chat_id, db=db)
if chat and chat.meta:
for tag in chat.meta.get('tags', []):
tag_counts[tag] += 1
# Sort by count and take top 10
+157 -119
View File
@@ -52,7 +52,6 @@ from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
ENV,
)
from open_webui.models.config import Config
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
@@ -72,50 +71,6 @@ AZURE_MAX_FILE_SIZE: int = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024
SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech'
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
TTS_CONFIG_KEYS = {
'OPENAI_API_BASE_URL': 'audio.tts.openai.api_base_url',
'OPENAI_API_KEY': 'audio.tts.openai.api_key',
'OPENAI_PARAMS': 'audio.tts.openai.params',
'API_KEY': 'audio.tts.api_key',
'ENGINE': 'audio.tts.engine',
'MODEL': 'audio.tts.model',
'VOICE': 'audio.tts.voice',
'SPLIT_ON': 'audio.tts.split_on',
'AZURE_SPEECH_REGION': 'audio.tts.azure.speech_region',
'AZURE_SPEECH_BASE_URL': 'audio.tts.azure.speech_base_url',
'AZURE_SPEECH_OUTPUT_FORMAT': 'audio.tts.azure.speech_output_format',
'MISTRAL_API_KEY': 'audio.tts.mistral.api_key',
'MISTRAL_API_BASE_URL': 'audio.tts.mistral.api_base_url',
}
STT_CONFIG_KEYS = {
'OPENAI_API_BASE_URL': 'audio.stt.openai.api_base_url',
'OPENAI_API_KEY': 'audio.stt.openai.api_key',
'ENGINE': 'audio.stt.engine',
'MODEL': 'audio.stt.model',
'SUPPORTED_CONTENT_TYPES': 'audio.stt.supported_content_types',
'ALLOWED_EXTENSIONS': 'audio.stt.allowed_extensions',
'WHISPER_MODEL': 'audio.stt.whisper_model',
'DEEPGRAM_API_KEY': 'audio.stt.deepgram.api_key',
'AZURE_API_KEY': 'audio.stt.azure.api_key',
'AZURE_REGION': 'audio.stt.azure.region',
'AZURE_LOCALES': 'audio.stt.azure.locales',
'AZURE_BASE_URL': 'audio.stt.azure.base_url',
'AZURE_MAX_SPEAKERS': 'audio.stt.azure.max_speakers',
'MISTRAL_API_KEY': 'audio.stt.mistral.api_key',
'MISTRAL_API_BASE_URL': 'audio.stt.mistral.api_base_url',
'MISTRAL_USE_CHAT_COMPLETIONS': 'audio.stt.mistral.use_chat_completions',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
return {key_map[field]: value for field, value in data.items() if field in key_map}
def is_audio_conversion_required(file_path):
"""
@@ -273,28 +228,119 @@ class AudioConfigUpdateForm(BaseModel):
@router.get('/config')
async def get_audio_config(request: Request, user=Depends(get_admin_user)):
return {
'tts': await get_config_values(TTS_CONFIG_KEYS),
'stt': await get_config_values(STT_CONFIG_KEYS),
'tts': {
'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL,
'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY,
'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS,
'API_KEY': request.app.state.config.TTS_API_KEY,
'ENGINE': request.app.state.config.TTS_ENGINE,
'MODEL': request.app.state.config.TTS_MODEL,
'VOICE': request.app.state.config.TTS_VOICE,
'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON,
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
},
'stt': {
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY,
'ENGINE': request.app.state.config.STT_ENGINE,
'MODEL': request.app.state.config.STT_MODEL,
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION,
'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES,
'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
},
}
@router.post('/config/update')
async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)):
await Config.upsert(
{
**config_updates(form_data.tts.model_dump(), TTS_CONFIG_KEYS),
**config_updates(form_data.stt.model_dump(), STT_CONFIG_KEYS),
}
)
# TTS settings
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
request.app.state.config.TTS_VOICE = form_data.tts.VOICE
request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON
request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION
request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL
request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT
request.app.state.config.TTS_MISTRAL_API_KEY = form_data.tts.MISTRAL_API_KEY
request.app.state.config.TTS_MISTRAL_API_BASE_URL = form_data.tts.MISTRAL_API_BASE_URL
if form_data.stt.ENGINE == '':
# STT settings
request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
request.app.state.config.STT_MODEL = form_data.stt.MODEL
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES
request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY
request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION
request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES
request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = form_data.stt.AZURE_MAX_SPEAKERS
request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY
request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = form_data.stt.MISTRAL_API_BASE_URL
request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS
if request.app.state.config.STT_ENGINE == '':
request.app.state.faster_whisper_model = set_faster_whisper_model(
form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
)
else:
request.app.state.faster_whisper_model = None
return await get_audio_config(request, user)
return {
'tts': {
'ENGINE': request.app.state.config.TTS_ENGINE,
'MODEL': request.app.state.config.TTS_MODEL,
'VOICE': request.app.state.config.TTS_VOICE,
'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL,
'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY,
'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS,
'API_KEY': request.app.state.config.TTS_API_KEY,
'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON,
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
},
'stt': {
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY,
'ENGINE': request.app.state.config.STT_ENGINE,
'MODEL': request.app.state.config.STT_MODEL,
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION,
'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES,
'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
},
}
def load_speech_pipeline(request):
@@ -342,16 +388,14 @@ async def _write_tts_cache(
async def _tts_openai(request, payload, file_path, file_body_path, user):
"""Generate speech via an OpenAI-compatible TTS endpoint."""
payload['model'] = await Config.get('audio.tts.model')
payload['model'] = request.app.state.config.TTS_MODEL
if not payload.get('voice'):
payload['voice'] = await Config.get('audio.tts.voice')
payload = {**payload, **(await Config.get('audio.tts.openai.params') or {})}
api_key = await Config.get('audio.tts.openai.api_key')
api_base_url = await Config.get('audio.tts.openai.api_base_url')
payload['voice'] = request.app.state.config.TTS_VOICE
payload = {**payload, **(request.app.state.config.TTS_OPENAI_PARAMS or {})}
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
'Authorization': f'Bearer {request.app.state.config.TTS_OPENAI_API_KEY}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
@@ -360,7 +404,7 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
try:
session = await get_session()
r = await session.post(
url=f'{api_base_url}/audio/speech',
url=f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech',
json=payload,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -385,12 +429,8 @@ async def _tts_openai(request, payload, file_path, file_body_path, user):
async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
"""Generate speech via the ElevenLabs TTS API."""
voice_id = (payload.get('voice') or '').strip()
if not voice_id:
raise HTTPException(status_code=400, detail='Invalid voice id')
available_voices = await get_available_voices(request)
if available_voices and voice_id not in available_voices:
voice_id = payload.get('voice', '')
if voice_id not in await get_available_voices(request):
raise HTTPException(status_code=400, detail='Invalid voice id')
r = None
@@ -400,13 +440,13 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
f'{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}',
json={
'text': payload['input'],
'model_id': await Config.get('audio.tts.model'),
'model_id': request.app.state.config.TTS_MODEL,
'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5},
},
headers={
'Accept': 'audio/mpeg',
'Content-Type': 'application/json',
'xi-api-key': await Config.get('audio.tts.api_key'),
'xi-api-key': request.app.state.config.TTS_API_KEY,
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
@@ -420,11 +460,11 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
async def _tts_azure(request, payload, file_path, file_body_path, user):
"""Generate speech via Azure Cognitive Services TTS."""
az_region = await Config.get('audio.tts.azure.speech_region') or 'eastus'
az_base = await Config.get('audio.tts.azure.speech_base_url')
language = payload.get('voice') or await Config.get('audio.tts.voice')
az_region = request.app.state.config.TTS_AZURE_SPEECH_REGION or 'eastus'
az_base = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
language = payload.get('voice') or request.app.state.config.TTS_VOICE
locale = '-'.join(language.split('-')[:2])
output_format = await Config.get('audio.tts.azure.speech_output_format')
output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT
ssml = (
f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">'
@@ -438,7 +478,7 @@ async def _tts_azure(request, payload, file_path, file_body_path, user):
async with session.post(
(az_base or f'https://{az_region}.tts.speech.microsoft.com') + '/cognitiveservices/v1',
headers={
'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key'),
'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/ssml+xml',
'X-Microsoft-OutputFormat': output_format,
},
@@ -461,7 +501,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
load_speech_pipeline(request)
embeddings = request.app.state.speech_speaker_embeddings_dataset
model_name = await Config.get('audio.tts.model')
model_name = request.app.state.config.TTS_MODEL
idx = 6799
try:
@@ -489,8 +529,8 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user):
async def _tts_mistral(request, payload, file_path, file_body_path, user):
"""Generate speech via the Mistral TTS API."""
api_key = await Config.get('audio.tts.mistral.api_key')
api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
if not api_key:
raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral TTS')
@@ -502,7 +542,7 @@ async def _tts_mistral(request, payload, file_path, file_body_path, user):
url=f'{api_base_url}/audio/speech',
json={
'input': payload.get('input', ''), # text to synthesize
'model': await Config.get('audio.tts.model') or 'voxtral-mini-tts-2603',
'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603',
'voice_id': payload.get('voice', ''),
'response_format': 'mp3',
},
@@ -538,7 +578,7 @@ _TTS_ENGINES = {
@router.post('/speech')
async def speech(request: Request, user=Depends(get_verified_user)):
engine = await Config.get('audio.tts.engine')
engine = request.app.state.config.TTS_ENGINE
if engine == '':
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -546,7 +586,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
)
if user.role != 'admin' and not await has_permission(
user.id, 'chat.tts', await Config.get('user.permissions')
user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -555,7 +595,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(
body + str(engine).encode('utf-8') + str(await Config.get('audio.tts.model')).encode('utf-8')
body + str(engine).encode('utf-8') + str(request.app.state.config.TTS_MODEL).encode('utf-8')
).hexdigest()
file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3')
@@ -580,7 +620,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async def _transcribe_whisper(request, file_path, languages, file_dir, id):
if request.app.state.faster_whisper_model is None:
request.app.state.faster_whisper_model = set_faster_whisper_model(await Config.get('audio.stt.whisper_model'))
request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL)
model = request.app.state.faster_whisper_model
@@ -611,13 +651,11 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
try:
session = await get_session()
for language in languages:
payload = {'model': await Config.get('audio.stt.model')}
payload = {'model': request.app.state.config.STT_MODEL}
if language:
payload['language'] = language
api_key = await Config.get('audio.stt.openai.api_key')
api_base_url = await Config.get('audio.stt.openai.api_base_url')
headers = {'Authorization': f'Bearer {api_key}'}
headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'}
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
@@ -627,7 +665,7 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
form_data.add_field('file', open(file_path, 'rb'), filename=filename)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -661,8 +699,8 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
async with aiofiles.open(file_path, 'rb') as f:
audio_bytes = await f.read()
api_key = await Config.get('audio.stt.deepgram.api_key')
stt_model = await Config.get('audio.stt.model')
api_key = request.app.state.config.DEEPGRAM_API_KEY
stt_model = request.app.state.config.STT_MODEL
r = None
try:
@@ -729,11 +767,11 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
detail=f'File size ({audio_size // (1024 * 1024)}MB) exceeds Azure limit of {AZURE_MAX_FILE_SIZE_MB}MB',
)
api_key = await Config.get('audio.stt.azure.api_key')
region = await Config.get('audio.stt.azure.region') or 'eastus'
locale_str = await Config.get('audio.stt.azure.locales')
base_url = await Config.get('audio.stt.azure.base_url')
max_speakers = await Config.get('audio.stt.azure.max_speakers') or 3
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus'
locale_str = request.app.state.config.AUDIO_STT_AZURE_LOCALES
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3
# Default to a broad set of locales when none are configured
if len(locale_str) < 2:
@@ -843,16 +881,16 @@ async def transcription_handler(request, file_path, metadata, user=None):
None, # Always fallback to None in case transcription fails
]
if await Config.get('audio.stt.engine') == '':
if request.app.state.config.STT_ENGINE == '':
return await _transcribe_whisper(request, file_path, languages, file_dir, id)
elif await Config.get('audio.stt.engine') == 'openai':
elif request.app.state.config.STT_ENGINE == 'openai':
return await _transcribe_openai(request, file_path, filename, languages, file_dir, id, user)
elif await Config.get('audio.stt.engine') == 'deepgram':
elif request.app.state.config.STT_ENGINE == 'deepgram':
return await _transcribe_deepgram(request, file_path, languages, file_dir, id)
elif await Config.get('audio.stt.engine') == 'azure':
elif request.app.state.config.STT_ENGINE == 'azure':
return await _transcribe_azure(request, file_path, filename, file_dir, id)
elif await Config.get('audio.stt.engine') == 'mistral':
elif request.app.state.config.STT_ENGINE == 'mistral':
return await _transcribe_mistral(request, file_path, filename, metadata, file_dir, id)
@@ -865,16 +903,16 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
if file_size > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB')
api_key = await Config.get('audio.stt.mistral.api_key')
api_base_url = await Config.get('audio.stt.mistral.api_base_url') or 'https://api.mistral.ai/v1'
use_chat_completions = await Config.get('audio.stt.mistral.use_chat_completions')
api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
if not api_key:
raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral STT')
r = None
try:
model = await Config.get('audio.stt.model') or 'voxtral-mini-latest'
model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest'
log.info(
f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}'
)
@@ -1116,14 +1154,14 @@ async def transcription(
user=Depends(get_verified_user),
):
if user.role != 'admin' and not await has_permission(
user.id, 'chat.stt', await Config.get('user.permissions')
user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f'file.content_type: {file.content_type}')
stt_supported_content_types = await Config.get('audio.stt.supported_content_types', [])
stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
if not strict_match_mime_type(stt_supported_content_types, file.content_type):
raise HTTPException(
@@ -1135,7 +1173,7 @@ async def transcription(
safe_name = os.path.basename(file.filename) if file.filename else ''
ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else ''
allowed_extensions = await Config.get('audio.stt.allowed_extensions', [])
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,
@@ -1195,11 +1233,11 @@ async def transcription(
async def get_available_models(request: Request) -> list[dict]:
"""Return the list of available TTS models for the configured engine."""
available_models = []
engine = await Config.get('audio.tts.engine')
engine = request.app.state.config.TTS_ENGINE
_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
if engine == 'openai':
base_url = await Config.get('audio.tts.openai.api_base_url')
base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL
if not base_url.startswith('https://api.openai.com'):
session = await get_session()
try:
@@ -1234,7 +1272,7 @@ async def get_available_models(request: Request) -> list[dict]:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/models',
headers={
'xi-api-key': await Config.get('audio.tts.api_key'),
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -1269,11 +1307,11 @@ _OPENAI_DEFAULT_VOICES = {
async def get_available_voices(request) -> dict:
"""Return ``{voice_id: voice_name}`` for the configured TTS engine."""
engine = await Config.get('audio.tts.engine')
engine = request.app.state.config.TTS_ENGINE
_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
if engine == 'openai':
base_url = await Config.get('audio.tts.openai.api_base_url')
base_url = request.app.state.config.TTS_OPENAI_API_BASE_URL
if not base_url.startswith('https://api.openai.com'):
try:
session = await get_session()
@@ -1296,7 +1334,7 @@ async def get_available_voices(request) -> dict:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
headers={
'xi-api-key': await Config.get('audio.tts.api_key'),
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -1306,19 +1344,19 @@ async def get_available_voices(request) -> dict:
voices_data = await resp.json()
return {v['voice_id']: v['name'] for v in voices_data.get('voices', [])}
except Exception as e:
log.warning(f'Error fetching ElevenLabs voices: {e}')
log.error(f'Error fetching ElevenLabs voices: {e}')
return {}
if engine == 'azure':
try:
region = await Config.get('audio.tts.azure.speech_region')
base_url = await Config.get('audio.tts.azure.speech_base_url')
region = request.app.state.config.TTS_AZURE_SPEECH_REGION
base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list'
session = await get_session()
async with session.get(
url,
headers={'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key')},
headers={'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=_timeout,
) as resp:
@@ -1330,8 +1368,8 @@ async def get_available_voices(request) -> dict:
return {}
if engine == 'mistral':
api_key = await Config.get('audio.tts.mistral.api_key')
api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
if api_key:
try:
session = await get_session()
+206 -283
View File
@@ -8,15 +8,21 @@ import time
import urllib
import uuid
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
from typing import List, Optional
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from fastapi.responses import JSONResponse, RedirectResponse, Response
from ldap3 import NONE, Connection, Server, Tls
from ldap3.utils.conv import escape_filter_chars
from open_webui.config import (
ENABLE_LDAP,
ENABLE_OAUTH_SIGNUP,
ENABLE_PASSWORD_AUTH,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
OAUTH_PROVIDERS,
OPENID_END_SESSION_ENDPOINT,
OPENID_PROVIDER_URL,
)
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
@@ -44,7 +50,6 @@ from open_webui.models.auths import (
Token,
UpdatePasswordForm,
)
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import (
@@ -70,6 +75,7 @@ from open_webui.utils.auth import (
)
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.oauth import auth_manager_config
from open_webui.utils.rate_limit import RateLimiter
from open_webui.utils.redis import get_redis_client
from open_webui.utils.webhook import post_webhook
@@ -84,60 +90,6 @@ log = logging.getLogger(__name__)
# who exceed their allotted rate against this gate.
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
ADMIN_CONFIG_KEYS = {
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
'ADMIN_EMAIL': 'auth.admin.email',
'WEBUI_URL': 'webui.url',
'ENABLE_SIGNUP': 'ui.enable_signup',
'ENABLE_API_KEYS': 'auth.enable_api_keys',
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': 'auth.api_key.endpoint_restrictions',
'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints',
'DEFAULT_USER_ROLE': 'ui.default_user_role',
'DEFAULT_GROUP_ID': 'ui.default_group_id',
'JWT_EXPIRES_IN': 'auth.jwt_expiry',
'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing',
'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating',
'ENABLE_FOLDERS': 'folders.enable',
'FOLDER_MAX_FILE_COUNT': 'folders.max_file_count',
'AUTOMATION_MAX_COUNT': 'automations.max_count',
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
'ENABLE_AUTOMATIONS': 'automations.enable',
'ENABLE_CHANNELS': 'channels.enable',
'ENABLE_CALENDAR': 'calendar.enable',
'ENABLE_MEMORIES': 'memories.enable',
'ENABLE_NOTES': 'notes.enable',
'ENABLE_USER_WEBHOOKS': 'ui.enable_user_webhooks',
'ENABLE_USER_STATUS': 'users.enable_status',
'PENDING_USER_OVERLAY_TITLE': 'ui.pending_user_overlay_title',
'PENDING_USER_OVERLAY_CONTENT': 'ui.pending_user_overlay_content',
'RESPONSE_WATERMARK': 'ui.watermark',
}
LDAP_SERVER_CONFIG_KEYS = {
'label': 'ldap.server.label',
'host': 'ldap.server.host',
'port': 'ldap.server.port',
'attribute_for_mail': 'ldap.server.attribute_for_mail',
'attribute_for_username': 'ldap.server.attribute_for_username',
'app_dn': 'ldap.server.app_dn',
'app_dn_password': 'ldap.server.app_password',
'search_base': 'ldap.server.users_dn',
'search_filters': 'ldap.server.search_filter',
'use_tls': 'ldap.server.use_tls',
'certificate_path': 'ldap.server.ca_cert_file',
'validate_cert': 'ldap.server.validate_cert',
'ciphers': 'ldap.server.ciphers',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
return {key_map[field]: value for field, value in data.items() if field in key_map}
async def create_session_response(
request: Request, user, db, response: Response = None, set_cookie: bool = False
@@ -153,7 +105,7 @@ async def create_session_response(
response: FastAPI response object (required if set_cookie is True)
set_cookie: Whether to set the auth cookie on the response
"""
expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
@@ -176,7 +128,7 @@ async def create_session_response(
**({'max_age': max_age} if max_age is not None else {}),
)
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
return {
'token': token,
@@ -249,7 +201,7 @@ async def get_session_user(
**({'max_age': max_age} if max_age is not None else {}),
)
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
response_data = {
'token': token,
@@ -368,7 +320,7 @@ async def ldap_auth(
db: AsyncSession = Depends(get_async_session),
):
# Security checks FIRST - before loading any config
if not await Config.get('ldap.enable'):
if not request.app.state.config.ENABLE_LDAP:
raise HTTPException(400, detail='LDAP authentication is not enabled')
if not ENABLE_PASSWORD_AUTH:
@@ -386,19 +338,19 @@ async def ldap_auth(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# NOW load LDAP config variables
LDAP_SERVER_LABEL = await Config.get('ldap.server.label')
LDAP_SERVER_HOST = await Config.get('ldap.server.host')
LDAP_SERVER_PORT = await Config.get('ldap.server.port')
LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail')
LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username')
LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn')
LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter')
LDAP_APP_DN = await Config.get('ldap.server.app_dn')
LDAP_APP_PASSWORD = await Config.get('ldap.server.app_password')
LDAP_USE_TLS = await Config.get('ldap.server.use_tls')
LDAP_CA_CERT_FILE = await Config.get('ldap.server.ca_cert_file')
LDAP_VALIDATE_CERT = CERT_REQUIRED if await Config.get('ldap.server.validate_cert') else CERT_NONE
LDAP_CIPHERS = await Config.get('ldap.server.ciphers') if await Config.get('ldap.server.ciphers') else 'ALL'
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
LDAP_APP_DN = request.app.state.config.LDAP_APP_DN
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
LDAP_VALIDATE_CERT = CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE
LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL'
try:
tls = Tls(
@@ -429,9 +381,9 @@ async def ldap_auth(
if not await asyncio.to_thread(connection_app.bind):
raise HTTPException(400, detail='Application account bind failed')
ENABLE_LDAP_GROUP_MANAGEMENT = await Config.get('ldap.group.enable_management')
ENABLE_LDAP_GROUP_CREATION = await Config.get('ldap.group.enable_creation')
LDAP_ATTRIBUTE_FOR_GROUPS = await Config.get('ldap.server.attribute_for_groups')
ENABLE_LDAP_GROUP_MANAGEMENT = request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT
ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION
LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS
search_attributes = [
f'{LDAP_ATTRIBUTE_FOR_USERNAME}',
@@ -548,7 +500,7 @@ async def ldap_auth(
email=email,
password=str(uuid.uuid4()),
name=cn,
role=await Config.get('ui.default_user_role'),
role=request.app.state.config.DEFAULT_USER_ROLE,
db=db,
)
@@ -562,15 +514,15 @@ async def ldap_auth(
user = await Users.get_user_by_id(user.id, db=db)
await apply_default_group_assignment(
await Config.get('ui.default_group_id'),
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
if await Config.get('webhook_url'):
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
await Config.get('webhook_url'),
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
'action': 'signup',
@@ -751,7 +703,7 @@ async def signup_handler(
password=hashed,
name=name,
profile_image_url=profile_image_url,
role=await Config.get('ui.default_user_role'),
role=request.app.state.config.DEFAULT_USER_ROLE,
db=db,
)
if not user:
@@ -762,12 +714,12 @@ async def signup_handler(
if await Users.get_num_users(db=db) == 1:
await Users.update_user_role_by_id(user.id, 'admin', db=db)
user = await Users.get_user_by_id(user.id, db=db)
await Config.upsert({'ui.enable_signup': False})
request.app.state.config.ENABLE_SIGNUP = False
if await Config.get('webhook_url'):
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
await Config.get('webhook_url'),
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
'action': 'signup',
@@ -777,7 +729,7 @@ async def signup_handler(
)
await apply_default_group_assignment(
await Config.get('ui.default_group_id'),
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
@@ -796,10 +748,10 @@ async def signup(
if WEBUI_AUTH:
if has_users:
if not await Config.get('ui.enable_signup') or not await Config.get('ui.enable_login_form'):
if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
# Don't gate the first admin on ENABLE_SIGNUP: it auto-disables and can persist stale across a DB reset.
elif not await Config.get('ui.enable_login_form') and not ENABLE_INITIAL_ADMIN_SIGNUP:
elif not request.app.state.config.ENABLE_LOGIN_FORM and not ENABLE_INITIAL_ADMIN_SIGNUP:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
else:
if has_users:
@@ -860,21 +812,19 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
# If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
# there directly instead of attempting OIDC discovery.
openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint')
if openid_end_session_endpoint:
if OPENID_END_SESSION_ENDPOINT.value:
return JSONResponse(
status_code=200,
content={
'status': True,
'redirect_url': openid_end_session_endpoint,
'redirect_url': OPENID_END_SESSION_ENDPOINT.value,
},
headers=response.headers,
)
openid_provider_url = await Config.get('oauth.provider_url')
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None
) or openid_provider_url
) or OPENID_PROVIDER_URL.value
if session and oauth_server_metadata_url:
oauth_id_token = session.token.get('id_token')
@@ -984,12 +934,12 @@ async def add_user(
if user:
await apply_default_group_assignment(
await Config.get('ui.default_group_id'),
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
token = create_token(data={'id': user.id}, expires_delta=expires_delta)
return {
'token': token,
@@ -1018,8 +968,8 @@ async def add_user(
async def get_admin_details(
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
):
if await Config.get('auth.admin.show'):
admin_email = await Config.get('auth.admin.email')
if request.app.state.config.SHOW_ADMIN_DETAILS:
admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None
log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}')
@@ -1049,7 +999,34 @@ async def get_admin_details(
@router.get('/admin/config')
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(ADMIN_CONFIG_KEYS)
return {
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
'WEBUI_URL': request.app.state.config.WEBUI_URL,
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
}
class AdminConfig(BaseModel):
@@ -1083,24 +1060,81 @@ class AdminConfig(BaseModel):
@router.post('/admin/config')
async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)):
updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS)
updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
updates['automations.min_interval'] = (
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = form_data.API_KEYS_ALLOWED_ENDPOINTS
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
)
request.app.state.config.AUTOMATION_MAX_COUNT = (
int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
)
request.app.state.config.AUTOMATION_MIN_INTERVAL = (
int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else ''
)
request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
updates.pop('ui.default_user_role', None)
if form_data.DEFAULT_USER_ROLE in ['pending', 'user', 'admin']:
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
# Check if the input string matches the pattern
if not re.match(pattern, form_data.JWT_EXPIRES_IN):
updates.pop('auth.jwt_expiry', None)
if re.match(pattern, form_data.JWT_EXPIRES_IN):
request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN
await Config.upsert(updates)
return await get_config_values(ADMIN_CONFIG_KEYS)
request.app.state.config.ENABLE_COMMUNITY_SHARING = form_data.ENABLE_COMMUNITY_SHARING
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
request.app.state.config.PENDING_USER_OVERLAY_TITLE = form_data.PENDING_USER_OVERLAY_TITLE
request.app.state.config.PENDING_USER_OVERLAY_CONTENT = form_data.PENDING_USER_OVERLAY_CONTENT
request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK
return {
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
'WEBUI_URL': request.app.state.config.WEBUI_URL,
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
}
class LdapServerConfig(BaseModel):
@@ -1121,7 +1155,21 @@ class LdapServerConfig(BaseModel):
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
return {
'label': request.app.state.config.LDAP_SERVER_LABEL,
'host': request.app.state.config.LDAP_SERVER_HOST,
'port': request.app.state.config.LDAP_SERVER_PORT,
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
'app_dn': request.app.state.config.LDAP_APP_DN,
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
'use_tls': request.app.state.config.LDAP_USE_TLS,
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
'ciphers': request.app.state.config.LDAP_CIPHERS,
}
@router.post('/admin/config/ldap/server')
@@ -1138,16 +1186,40 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
if not value:
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
updates['ldap.server.app_dn'] = form_data.app_dn or ''
updates['ldap.server.app_password'] = form_data.app_dn_password or ''
await Config.upsert(updates)
return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ''
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ''
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
return {
'label': request.app.state.config.LDAP_SERVER_LABEL,
'host': request.app.state.config.LDAP_SERVER_HOST,
'port': request.app.state.config.LDAP_SERVER_PORT,
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
'app_dn': request.app.state.config.LDAP_APP_DN,
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
'use_tls': request.app.state.config.LDAP_USE_TLS,
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
'ciphers': request.app.state.config.LDAP_CIPHERS,
}
@router.get('/admin/config/ldap')
async def get_ldap_config(request: Request, user=Depends(get_admin_user)):
return {'ENABLE_LDAP': await Config.get('ldap.enable')}
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
class LdapConfigForm(BaseModel):
@@ -1156,8 +1228,8 @@ class LdapConfigForm(BaseModel):
@router.post('/admin/config/ldap')
async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)):
await Config.upsert({'ldap.enable': form_data.enable_ldap})
return {'ENABLE_LDAP': await Config.get('ldap.enable')}
request.app.state.config.ENABLE_LDAP = form_data.enable_ldap
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
############################
@@ -1165,162 +1237,19 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D
############################
class OAuthConfigForm(BaseModel):
"""All OAuth/OIDC settings exposed to the admin panel."""
# General OAuth
ENABLE_OAUTH_SIGNUP: bool | None = None
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
OAUTH_AUTO_REDIRECT: bool | None = None
OAUTH_ALLOWED_DOMAINS: str | None = None
OAUTH_BLOCKED_GROUPS: str | None = None
# Role management
ENABLE_OAUTH_ROLE_MANAGEMENT: bool | None = None
OAUTH_ROLES_CLAIM: str | None = None
OAUTH_ADMIN_ROLES: str | None = None
OAUTH_ALLOWED_ROLES: str | None = None
# Group management
ENABLE_OAUTH_GROUP_MANAGEMENT: bool | None = None
ENABLE_OAUTH_GROUP_CREATION: bool | None = None
OAUTH_GROUP_CLAIM: str | None = None
OAUTH_GROUP_DEFAULT_SHARE: bool | str | None = None
# OIDC provider settings
OAUTH_PROVIDER_NAME: str | None = None
OPENID_PROVIDER_URL: str | None = None
OAUTH_CLIENT_ID: str | None = None
OAUTH_CLIENT_SECRET: str | None = None
OPENID_REDIRECT_URI: str | None = None
OAUTH_SCOPES: str | None = None
OAUTH_CODE_CHALLENGE_METHOD: str | None = None
OAUTH_TOKEN_ENDPOINT_AUTH_METHOD: str | None = None
OPENID_END_SESSION_ENDPOINT: str | None = None
OAUTH_TIMEOUT: int | str | None = None
OAUTH_CLIENT_TIMEOUT: int | str | None = None
# Claims
OAUTH_EMAIL_CLAIM: str | None = None
OAUTH_USERNAME_CLAIM: str | None = None
OAUTH_PICTURE_CLAIM: str | None = None
OAUTH_SUB_CLAIM: str | None = None
OAUTH_AUDIENCE: str | None = None
# Profile update toggles
OAUTH_UPDATE_EMAIL_ON_LOGIN: bool | None = None
OAUTH_UPDATE_NAME_ON_LOGIN: bool | None = None
OAUTH_UPDATE_PICTURE_ON_LOGIN: bool | None = None
# Token
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE: bool | None = None
OAUTH_COMMA_LIST_FIELDS = {
'OAUTH_ALLOWED_DOMAINS',
'OAUTH_ADMIN_ROLES',
'OAUTH_ALLOWED_ROLES',
}
OAUTH_CONFIG_KEYS = {
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
'OAUTH_ALLOWED_DOMAINS': 'oauth.allowed_domains',
'OAUTH_BLOCKED_GROUPS': 'oauth.blocked_groups',
'ENABLE_OAUTH_ROLE_MANAGEMENT': 'oauth.enable_role_mapping',
'OAUTH_ROLES_CLAIM': 'oauth.roles_claim',
'OAUTH_ADMIN_ROLES': 'oauth.admin_roles',
'OAUTH_ALLOWED_ROLES': 'oauth.allowed_roles',
'ENABLE_OAUTH_GROUP_MANAGEMENT': 'oauth.enable_group_mapping',
'ENABLE_OAUTH_GROUP_CREATION': 'oauth.enable_group_creation',
'OAUTH_GROUP_CLAIM': 'oauth.group_claim',
'OAUTH_GROUP_DEFAULT_SHARE': 'oauth.group_default_share',
'OAUTH_PROVIDER_NAME': 'oauth.provider_name',
'OPENID_PROVIDER_URL': 'oauth.provider_url',
'OAUTH_CLIENT_ID': 'oauth.client_id',
'OAUTH_CLIENT_SECRET': 'oauth.client_secret',
'OPENID_REDIRECT_URI': 'oauth.redirect_uri',
'OAUTH_SCOPES': 'oauth.scopes',
'OAUTH_CODE_CHALLENGE_METHOD': 'oauth.code_challenge_method',
'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD': 'oauth.token_endpoint_auth_method',
'OPENID_END_SESSION_ENDPOINT': 'oauth.end_session_endpoint',
'OAUTH_TIMEOUT': 'oauth.timeout',
'OAUTH_CLIENT_TIMEOUT': 'oauth.client.timeout',
'OAUTH_EMAIL_CLAIM': 'oauth.email_claim',
'OAUTH_USERNAME_CLAIM': 'oauth.username_claim',
'OAUTH_PICTURE_CLAIM': 'oauth.picture_claim',
'OAUTH_SUB_CLAIM': 'oauth.sub_claim',
'OAUTH_AUDIENCE': 'oauth.audience',
'OAUTH_UPDATE_EMAIL_ON_LOGIN': 'oauth.update_email_on_login',
'OAUTH_UPDATE_NAME_ON_LOGIN': 'oauth.update_name_on_login',
'OAUTH_UPDATE_PICTURE_ON_LOGIN': 'oauth.update_picture_on_login',
'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': 'oauth.refresh_token.include_scope',
}
def _format_oauth_form_value(field: str, value):
if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, list):
return ','.join(str(item) for item in value)
return value
def _parse_oauth_update_value(field: str, value):
if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, str):
return [item.strip() for item in value.split(',') if item.strip()]
if field in {'OAUTH_TIMEOUT', 'OAUTH_CLIENT_TIMEOUT'} and value == '':
return ''
return value
async def get_oauth_config_values() -> dict:
values = await Config.get_many(*OAUTH_CONFIG_KEYS.values())
return {
field: _format_oauth_form_value(field, values[storage_key])
for field, storage_key in OAUTH_CONFIG_KEYS.items()
if storage_key in values
}
def oauth_config_updates(data: dict) -> dict:
return {
OAUTH_CONFIG_KEYS[field]: _parse_oauth_update_value(field, value)
for field, value in data.items()
if field in OAUTH_CONFIG_KEYS
}
@router.get('/admin/config/oauth', response_model=OAuthConfigForm)
async def get_oauth_config(request: Request, user=Depends(get_admin_user)):
return await get_oauth_config_values()
@router.post('/admin/config/oauth', response_model=OAuthConfigForm)
async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)):
await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True)))
return await get_oauth_config_values()
async def _check_api_key_permission(request: Request, user, db: AsyncSession):
if not await Config.get('auth.enable_api_keys') or (
user.role != 'admin'
and not await has_permission(
user.id, 'features.api_keys', await Config.get('user.permissions'), db=db
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
)
# create api key
@router.post('/api_key', response_model=ApiKey)
async def generate_api_key(
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
):
await _check_api_key_permission(request, user, db)
if not request.app.state.config.ENABLE_API_KEYS or (
user.role != 'admin'
and not await has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
)
api_key = create_api_key()
success = await Users.update_user_api_key_by_id(user.id, api_key, db=db)
@@ -1335,19 +1264,13 @@ async def generate_api_key(
# delete api key
@router.delete('/api_key', response_model=bool)
async def delete_api_key(
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
):
await _check_api_key_permission(request, user, db)
async def delete_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
return await Users.delete_user_api_key_by_id(user.id, db=db)
# get api key
@router.get('/api_key', response_model=ApiKey)
async def get_api_key(
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
):
await _check_api_key_permission(request, user, db)
async def get_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
api_key = await Users.get_user_api_key_by_id(user.id, db=db)
if api_key:
return {
@@ -1419,11 +1342,11 @@ async def token_exchange(
)
# Extract user information from the token claims
email_claim = await Config.get('oauth.email_claim', 'email')
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
# Get sub claim
sub_claim = await Config.get('oauth.sub_claim')
sub = user_data.get(sub_claim or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
sub = user_data.get(request.app.state.config.OAUTH_SUB_CLAIM or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
if not sub:
log.warning(f'Token exchange failed: sub claim missing from user data')
raise HTTPException(
@@ -1441,10 +1364,10 @@ async def token_exchange(
email = email.lower()
# Enforce domain allowlist — same check as the normal OAuth callback
oauth_allowed_domains = await Config.get('oauth.allowed_domains', [])
if isinstance(oauth_allowed_domains, str):
oauth_allowed_domains = [domain.strip() for domain in oauth_allowed_domains.split(',') if domain.strip()]
if '*' not in oauth_allowed_domains and email.split('@')[-1] not in oauth_allowed_domains:
if (
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(f'Token exchange denied: email domain not in allowed domains list')
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -1454,7 +1377,7 @@ async def token_exchange(
# Try to find the user by OAuth sub
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user and await Config.get('oauth.merge_accounts_by_email'):
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
# Try to find by email if merge is enabled
user = await Users.get_user_by_email(email, db=db)
if user:
+4 -6
View File
@@ -14,7 +14,6 @@ from open_webui.models.automations import (
AutomationRuns,
Automations,
)
from open_webui.models.config import Config
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.automations import (
@@ -39,14 +38,13 @@ PAGE_ITEM_COUNT = 30
async def check_automations_permission(request, user):
config = await Config.get_many('automations.enable', 'user.permissions')
if not config.get('automations.enable'):
if not request.app.state.config.ENABLE_AUTOMATIONS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.automations', config.get('user.permissions')
user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -74,7 +72,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
# Max count (create only)
if is_create:
max_count = await Config.get('automations.max_count')
max_count = request.app.state.config.AUTOMATION_MAX_COUNT
if max_count:
max_count = int(max_count)
if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count:
@@ -84,7 +82,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
)
# Min interval (create + update)
min_interval = await Config.get('automations.min_interval')
min_interval = request.app.state.config.AUTOMATION_MIN_INTERVAL
if min_interval:
min_interval = int(min_interval)
if min_interval > 0:
+6 -9
View File
@@ -19,7 +19,6 @@ from open_webui.models.calendar import (
CalendarUpdateForm,
RSVPForm,
)
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
@@ -35,14 +34,13 @@ SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__'
async def check_calendar_permission(request: Request, user):
"""Check global feature flag AND per-user permission for calendar access."""
config = await Config.get_many('calendar.enable', 'user.permissions')
if not config.get('calendar.enable'):
if not request.app.state.config.ENABLE_CALENDAR:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.calendar', config.get('user.permissions')
user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -52,12 +50,11 @@ async def check_calendar_permission(request: Request, user):
async def _user_has_automations(request: Request, user) -> bool:
"""Check if automations feature is available to this user."""
config = await Config.get_many('automations.enable', 'user.permissions')
if not config.get('automations.enable', False):
if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False):
return False
if user.role == 'admin':
return True
return await has_permission(user.id, 'features.automations', config.get('user.permissions'))
return await has_permission(user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS)
async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel:
@@ -119,7 +116,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM
# could create a calendar with `principal_id='*' permission='read'|'write'`,
# making their events readable or writable by any other verified user.
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -376,7 +373,7 @@ async def update_calendar(
# publicly readable/writable without the corresponding sharing permission.
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
+12 -44
View File
@@ -11,7 +11,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import STATIC_DIR
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant
from open_webui.models.config import Config
from open_webui.models.channels import (
ChannelForm,
ChannelModel,
@@ -32,7 +31,9 @@ from open_webui.models.messages import (
from open_webui.models.users import (
UserIdNameResponse,
UserIdNameStatusResponse,
UserListResponse,
UserModel,
UserModelResponse,
UserNameResponse,
Users,
)
@@ -124,7 +125,7 @@ def get_channel_permitted_group_and_user_ids(
async def check_channels_access(request: Request, user: Optional[UserModel] = None):
"""Dependency to ensure channels are globally enabled."""
if not await Config.get('channels.enable'):
if not request.app.state.config.ENABLE_CHANNELS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'),
@@ -132,7 +133,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No
if user:
if user.role != 'admin' and not await has_permission(
user.id, 'features.channels', await Config.get('user.permissions')
user.id, 'features.channels', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -293,7 +294,7 @@ async def create_new_channel(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -439,40 +440,7 @@ async def get_channel_by_id(
PAGE_ITEM_COUNT = 30
class ChannelMemberResponse(BaseModel):
id: str
email: str
name: str
role: str
profile_image_url: str | None = None
presence_state: str | None = None
status_emoji: str | None = None
status_message: str | None = None
status_expires_at: int | None = None
is_active: bool = False
class ChannelMemberListResponse(BaseModel):
users: list[ChannelMemberResponse]
total: int
def serialize_channel_member(user: UserModel) -> ChannelMemberResponse:
return ChannelMemberResponse(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
profile_image_url=user.profile_image_url,
presence_state=user.presence_state,
status_emoji=user.status_emoji,
status_message=user.status_message,
status_expires_at=user.status_expires_at,
is_active=Users.is_active(user),
)
@router.get('/{id}/members', response_model=ChannelMemberListResponse)
@router.get('/{id}/members', response_model=UserListResponse)
async def get_channel_members_by_id(
request: Request,
id: str,
@@ -507,7 +475,7 @@ async def get_channel_members_by_id(
total = len(fetched_users)
return {
'users': [serialize_channel_member(u) for u in fetched_users],
'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users],
'total': total,
}
else:
@@ -535,7 +503,7 @@ async def get_channel_members_by_id(
total = result['total']
return {
'users': [serialize_channel_member(u) for u in fetched_users],
'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users],
'total': total,
}
@@ -664,7 +632,7 @@ async def update_channel_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -858,8 +826,8 @@ async def get_pinned_channel_messages(
async def send_notification(request, channel, message, active_user_ids, db=None):
name = request.app.state.WEBUI_NAME
webui_url = await Config.get('webui.url')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
webui_url = request.app.state.config.WEBUI_URL
enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS
users = await get_channel_users_with_access(channel, 'read', db=db)
@@ -1010,7 +978,7 @@ async def model_response_handler(request, channel, message, user, db=None):
)
tool_ids = _resolve_model_tool_ids(request.app, model_id)
features = await _resolve_model_features(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.
+16 -84
View File
@@ -12,7 +12,6 @@ from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.chats import (
AggregateChatStats,
ChatBody,
@@ -33,7 +32,6 @@ from open_webui.models.tags import TagModel, Tags
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.middleware import serialize_output
from open_webui.utils.misc import get_message_list
@@ -44,17 +42,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
async def require_chat_import_permission(request: Request, user, db: AsyncSession):
if user.role != 'admin' and not await has_permission(
user.id, 'chat.import', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
############################
# GetChatList
# Let the record outlive the session, so that what was
@@ -413,7 +400,7 @@ async def export_chat_stats(
user=Depends(get_verified_user),
):
# Check if the user has permission to share/export chats
if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')):
if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -462,7 +449,7 @@ async def export_single_chat_stats(
Returns ChatStatsExport for the specified chat.
"""
# Check if the user has permission to share/export chats
if (user.role != 'admin') and (not await Config.get('ui.enable_community_sharing')):
if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -509,7 +496,7 @@ async def delete_all_user_chats(
db: AsyncSession = Depends(get_async_session),
):
if user.role == 'user' and not await has_permission(
user.id, 'chat.delete', await Config.get('user.permissions')
user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -574,13 +561,10 @@ async def create_new_chat(
# to assume the column is clean. Also catches non-UUID / nonexistent IDs.
if form_data.folder_id is not None:
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
# Check shared folder write access
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
try:
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
@@ -597,13 +581,10 @@ async def create_new_chat(
@router.post('/import', response_model=list[ChatResponse])
async def import_chats(
request: Request,
form_data: ChatsImportForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_chat_import_permission(request, user, db)
try:
chats = await Chats.import_chats(user.id, form_data.chats, db=db)
return chats
@@ -819,19 +800,6 @@ async def get_archived_session_user_chat_list(
)
############################
# GetArchivedChatsCount
############################
@router.get('/archived/count', response_model=int)
async def get_archived_session_user_chat_count(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return await Chats.count_archived_chats_by_user_id(user.id, db=db)
############################
# ArchiveAllChats
############################
@@ -853,27 +821,10 @@ async def unarchive_all_chats(user=Depends(get_verified_user), db: AsyncSession
############################
# UnshareAllChats
# GetSharedChats
############################
@router.delete('/share/all', response_model=bool)
async def unshare_all_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
# Collect chat_ids that have shares so we can clear share_id and access grants
shared_list = await SharedChats.get_by_user_id(user.id, db=db)
chat_ids = [s.chat_id for s in shared_list]
# Delete all shared_chat rows for this user
result = await SharedChats.delete_all_by_user_id(user.id, db=db)
# Clear share_id on the original chats and remove access grants
for chat_id in chat_ids:
await Chats.update_chat_share_id_by_id(chat_id, None, db=db)
await AccessGrants.set_access_grants('shared_chat', chat_id, [], db=db)
return result
@router.get('/shared', response_model=list[SharedChatResponse])
async def get_shared_session_user_chat_list(
page: int | None = None,
@@ -1000,14 +951,6 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
# Check folder-based access (shared folders)
if not chat:
candidate = await Chats.get_chat_by_id(id, db=db)
if candidate and candidate.folder_id:
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
chat = candidate
if chat:
return ChatResponse(**chat.model_dump())
@@ -1195,7 +1138,7 @@ async def delete_chat_by_id(
return result
else:
if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
if not await has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -1255,14 +1198,11 @@ class CloneForm(BaseModel):
@router.post('/{id}/clone', response_model=ChatResponse | None)
async def clone_chat_by_id(
request: Request,
form_data: CloneForm,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
updated_chat = {
@@ -1306,13 +1246,8 @@ async def clone_chat_by_id(
@router.post('/{id}/clone/shared', response_model=ChatResponse | None)
async def clone_shared_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_share_id(id, db=db)
# Fallback: admins can also access any chat directly by chat ID
@@ -1415,7 +1350,7 @@ async def share_chat_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'chat.share', await Config.get('user.permissions')
user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
@@ -1491,7 +1426,7 @@ async def update_shared_chat_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -1558,13 +1493,10 @@ async def update_chat_folder_id_by_id(
# folder_id values. None is allowed (moves the chat out of any folder).
if form_data.folder_id is not None:
if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
# Check shared folder write access
shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db)
return ChatResponse(**chat.model_dump())
+116 -74
View File
@@ -7,8 +7,7 @@ from typing import Optional
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request
from mcp.shared.auth import OAuthMetadata
from open_webui.config import BannerModel
from open_webui.models.config import Config
from open_webui.config import BannerModel, async_save_config, get_config, save_config
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.utils.auth import get_admin_user, get_verified_user
@@ -35,44 +34,6 @@ router = APIRouter()
log = logging.getLogger(__name__)
CONNECTIONS_CONFIG_KEYS = {
'ENABLE_DIRECT_CONNECTIONS': 'direct.enable',
'ENABLE_BASE_MODELS_CACHE': 'models.base_models_cache',
}
CODE_EXECUTION_CONFIG_KEYS = {
'ENABLE_CODE_EXECUTION': 'code_execution.enable',
'CODE_EXECUTION_ENGINE': 'code_execution.engine',
'CODE_EXECUTION_JUPYTER_URL': 'code_execution.jupyter.url',
'CODE_EXECUTION_JUPYTER_AUTH': 'code_execution.jupyter.auth',
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': 'code_execution.jupyter.auth_token',
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': 'code_execution.jupyter.auth_password',
'CODE_EXECUTION_JUPYTER_TIMEOUT': 'code_execution.jupyter.timeout',
'ENABLE_CODE_INTERPRETER': 'code_interpreter.enable',
'CODE_INTERPRETER_ENGINE': 'code_interpreter.engine',
'CODE_INTERPRETER_PROMPT_TEMPLATE': 'code_interpreter.prompt_template',
'CODE_INTERPRETER_JUPYTER_URL': 'code_interpreter.jupyter.url',
'CODE_INTERPRETER_JUPYTER_AUTH': 'code_interpreter.jupyter.auth',
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': 'code_interpreter.jupyter.auth_token',
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': 'code_interpreter.jupyter.auth_password',
'CODE_INTERPRETER_JUPYTER_TIMEOUT': 'code_interpreter.jupyter.timeout',
}
MODELS_CONFIG_KEYS = {
'DEFAULT_MODELS': 'ui.default_models',
'DEFAULT_PINNED_MODELS': 'ui.default_pinned_models',
'MODEL_ORDER_LIST': 'ui.model_order_list',
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
'DEFAULT_MODEL_PARAMS': 'models.default_params',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
return {key_map[field]: value for field, value in data.items() if field in key_map}
############################
# ImportConfig
@@ -87,8 +48,9 @@ class ImportConfigForm(BaseModel):
@router.post('/import', response_model=dict)
async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)):
await Config.upsert(form_data.config)
return await Config.get_all()
await async_save_config(form_data.config)
request.app.state.config._sync_to_redis()
return get_config()
############################
@@ -98,12 +60,7 @@ async def import_config(request: Request, form_data: ImportConfigForm, user=Depe
@router.get('/export', response_model=dict)
async def export_config(user=Depends(get_admin_user)):
return await Config.get_all()
@router.get('/namespace/{namespace}', response_model=dict)
async def get_config_namespace(namespace: str, user=Depends(get_admin_user)):
return await Config.get_namespace(namespace)
return get_config()
############################
@@ -118,7 +75,10 @@ class ConnectionsConfigForm(BaseModel):
@router.get('/connections', response_model=ConnectionsConfigForm)
async def get_connections_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(CONNECTIONS_CONFIG_KEYS)
return {
'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE,
}
@router.post('/connections', response_model=ConnectionsConfigForm)
@@ -127,8 +87,13 @@ async def set_connections_config(
form_data: ConnectionsConfigForm,
user=Depends(get_admin_user),
):
await Config.upsert(config_updates(form_data.model_dump(), CONNECTIONS_CONFIG_KEYS))
return await get_config_values(CONNECTIONS_CONFIG_KEYS)
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = form_data.ENABLE_DIRECT_CONNECTIONS
request.app.state.config.ENABLE_BASE_MODELS_CACHE = form_data.ENABLE_BASE_MODELS_CACHE
return {
'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE,
}
class OAuthClientRegistrationForm(BaseModel):
@@ -202,7 +167,9 @@ class ToolServersConfigForm(BaseModel):
@router.get('/tool_servers', response_model=ToolServersConfigForm)
async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)):
return {'TOOL_SERVER_CONNECTIONS': await Config.get('tool_server.connections')}
return {
'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS,
}
@router.post('/tool_servers', response_model=ToolServersConfigForm)
@@ -211,8 +178,7 @@ async def set_tool_servers_config(
form_data: ToolServersConfigForm,
user=Depends(get_admin_user),
):
existing_connections = await Config.get('tool_server.connections', []) or []
for connection in existing_connections:
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
server_type = connection.get('type', 'openapi')
auth_type = connection.get('auth_type', 'none')
@@ -227,12 +193,13 @@ async def set_tool_servers_config(
pass
# Set new tool server connections
connections = [connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS]
await Config.upsert({'tool_server.connections': connections})
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
]
await set_tool_servers(request)
for connection in connections:
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
server_type = connection.get('type', 'openapi')
if server_type == 'mcp':
server_id = connection.get('info', {}).get('id')
@@ -249,7 +216,9 @@ async def set_tool_servers_config(
log.debug(f'Failed to add OAuth client for MCP tool server: {e}')
continue
return {'TOOL_SERVER_CONNECTIONS': connections}
return {
'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS,
}
class TerminalServerConnection(BaseModel):
@@ -280,7 +249,9 @@ class TerminalServersConfigForm(BaseModel):
@router.get('/terminal_servers')
async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)):
return {'TERMINAL_SERVER_CONNECTIONS': await Config.get('terminal_server.connections')}
return {
'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
}
@router.post('/terminal_servers')
@@ -289,12 +260,15 @@ async def set_terminal_servers_config(
form_data: TerminalServersConfigForm,
user=Depends(get_admin_user),
):
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
await Config.upsert({'terminal_server.connections': connections})
request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS
]
await set_terminal_servers(request)
return {'TERMINAL_SERVER_CONNECTIONS': connections}
return {
'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
}
@router.post('/terminal_servers/verify')
@@ -544,15 +518,67 @@ class CodeInterpreterConfigForm(BaseModel):
@router.get('/code_execution', response_model=CodeInterpreterConfigForm)
async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(CODE_EXECUTION_CONFIG_KEYS)
return {
'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION,
'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE,
'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER,
'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE,
'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
}
@router.post('/code_execution', response_model=CodeInterpreterConfigForm)
async def set_code_execution_config(
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
):
await Config.upsert(config_updates(form_data.model_dump(), CODE_EXECUTION_CONFIG_KEYS))
return await get_config_values(CODE_EXECUTION_CONFIG_KEYS)
request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = form_data.CODE_EXECUTION_JUPYTER_URL
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = form_data.CODE_EXECUTION_JUPYTER_AUTH
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = form_data.CODE_EXECUTION_JUPYTER_TIMEOUT
request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER
request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = form_data.CODE_INTERPRETER_PROMPT_TEMPLATE
request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = form_data.CODE_INTERPRETER_JUPYTER_URL
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = form_data.CODE_INTERPRETER_JUPYTER_AUTH
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT
return {
'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION,
'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE,
'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER,
'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE,
'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
}
############################
@@ -569,19 +595,35 @@ class ModelsConfigForm(BaseModel):
@router.get('/models/defaults')
async def get_models_defaults(request: Request, user=Depends(get_verified_user)):
return {
'DEFAULT_MODEL_METADATA': await Config.get('models.default_metadata'),
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
}
@router.get('/models', response_model=ModelsConfigForm)
async def get_models_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(MODELS_CONFIG_KEYS)
return {
'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS,
'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS,
'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST,
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS,
}
@router.post('/models', response_model=ModelsConfigForm)
async def set_models_config(request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)):
await Config.upsert(config_updates(form_data.model_dump(), MODELS_CONFIG_KEYS))
return await get_config_values(MODELS_CONFIG_KEYS)
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
return {
'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS,
'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS,
'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST,
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS,
}
class PromptSuggestion(BaseModel):
@@ -600,8 +642,8 @@ async def set_default_suggestions(
user=Depends(get_admin_user),
):
data = form_data.model_dump()
await Config.upsert({'ui.prompt_suggestions': data['suggestions']})
return await Config.get('ui.prompt_suggestions')
request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data['suggestions']
return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS
############################
@@ -620,8 +662,8 @@ async def set_banners(
user=Depends(get_admin_user),
):
data = form_data.model_dump()
await Config.upsert({'ui.banners': data['banners']})
return await Config.get('ui.banners')
request.app.state.config.BANNERS = data['banners']
return request.app.state.config.BANNERS
@router.get('/banners', response_model=list[BannerModel])
@@ -629,4 +671,4 @@ async def get_banners(
request: Request,
user=Depends(get_verified_user),
):
return await Config.get('ui.banners')
return request.app.state.config.BANNERS
+11 -17
View File
@@ -5,7 +5,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.feedbacks import (
FeedbackForm,
FeedbackIdResponse,
@@ -26,16 +25,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
EVALUATION_CONFIG_KEYS = {
'ENABLE_EVALUATION_ARENA_MODELS': 'evaluation.arena.enable',
'EVALUATION_ARENA_MODELS': 'evaluation.arena.models',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
# Leaderboard Elo Rating Computation
# The judgment has already been rendered with grace;
@@ -266,7 +255,10 @@ async def get_model_history(
@router.get('/config')
async def get_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(EVALUATION_CONFIG_KEYS)
return {
'ENABLE_EVALUATION_ARENA_MODELS': request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS,
'EVALUATION_ARENA_MODELS': request.app.state.config.EVALUATION_ARENA_MODELS,
}
############################
@@ -285,13 +277,15 @@ async def update_config(
form_data: UpdateConfigForm,
user=Depends(get_admin_user),
):
updates = {}
config = request.app.state.config
if form_data.ENABLE_EVALUATION_ARENA_MODELS is not None:
updates['evaluation.arena.enable'] = form_data.ENABLE_EVALUATION_ARENA_MODELS
config.ENABLE_EVALUATION_ARENA_MODELS = form_data.ENABLE_EVALUATION_ARENA_MODELS
if form_data.EVALUATION_ARENA_MODELS is not None:
updates['evaluation.arena.models'] = form_data.EVALUATION_ARENA_MODELS
await Config.upsert(updates)
return await get_config_values(EVALUATION_CONFIG_KEYS)
config.EVALUATION_ARENA_MODELS = form_data.EVALUATION_ARENA_MODELS
return {
'ENABLE_EVALUATION_ARENA_MODELS': config.ENABLE_EVALUATION_ARENA_MODELS,
'EVALUATION_ARENA_MODELS': config.EVALUATION_ARENA_MODELS,
}
@router.get('/feedbacks/models', response_model=list[str])
+19 -53
View File
@@ -26,7 +26,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_db_context, get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.files import (
FileForm,
@@ -124,7 +123,7 @@ async def process_uploaded_file(
if _is_text_file(file_path):
content_type = 'text/plain'
stt_supported = await Config.get('audio.stt.supported_content_types', [])
stt_supported = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
if content_type and strict_match_mime_type(stt_supported, content_type):
# Audio / STT-supported files → transcribe then index
@@ -145,7 +144,7 @@ async def process_uploaded_file(
elif (
content_type
and content_type.startswith(('image/', 'video/'))
and await Config.get('rag.content_extraction_engine') != 'external'
and request.app.state.config.CONTENT_EXTRACTION_ENGINE != 'external'
):
# Media files without an external extraction engine
if content_type.startswith('video/'):
@@ -179,39 +178,19 @@ async def process_uploaded_file(
knowledge_id = file_metadata.get('knowledge_id')
if knowledge_id:
try:
# Gate like POST /knowledge/{id}/file/add: a client-supplied
# metadata.knowledge_id must not let a non-writer attach files (CWE-862/863).
knowledge = await Knowledges.get_knowledge_by_id(id=knowledge_id, db=db_session)
can_write = bool(knowledge) and (
knowledge.user_id == user.id
or user.role == 'admin'
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge.id,
permission='write',
db=db_session,
)
await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
)
if not can_write:
log.warning(
f'Refusing to auto-link file {file_item.id} to knowledge '
f'{knowledge_id}: user {user.id} lacks write access'
)
else:
await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
)
await process_file(
request,
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
user=user,
db=db_session,
)
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
await process_file(
request,
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
user=user,
db=db_session,
)
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
except Exception as e:
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
@@ -289,11 +268,12 @@ async def upload_file_handler(
# Remove the leading dot from the file extension and lowercase it
file_extension = file_extension[1:].lower() if file_extension else ''
allowed_file_extensions = await Config.get('rag.file.allowed_extensions')
if process and allowed_file_extensions:
allowed_file_extensions = [ext for ext in allowed_file_extensions if ext]
if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
]
if file_extension not in allowed_file_extensions:
if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(f'File type {file_extension} is not allowed'),
@@ -463,20 +443,6 @@ async def search_files(
return files
############################
# Count Files
############################
@router.get('/count', response_model=int)
async def count_files(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
return await Files.count_files_by_user_id(user_id=user_id, db=db)
############################
# Delete All Files
############################
+38 -286
View File
@@ -11,7 +11,6 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.folders import (
FolderForm,
@@ -20,13 +19,7 @@ from open_webui.models.folders import (
Folders,
FolderUpdateForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import (
filter_allowed_access_grants,
)
from open_webui.utils.access_control.files import get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
@@ -38,29 +31,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
async def check_folders_permission(request: Request, user, db=None):
"""Verify the folders feature is enabled and the user has permission."""
config = await Config.get_many('folders.enable', 'user.permissions')
if config.get('folders.enable') is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != 'admin' and not await has_permission(
user.id,
'features.folders',
config.get('user.permissions'),
db=db,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
############################
# Get Folders
############################
@@ -72,7 +42,22 @@ async def get_folders(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
if request.app.state.config.ENABLE_FOLDERS is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != 'admin' and not await has_permission(
user.id,
'features.folders',
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folders = await Folders.get_folders_by_user_id(user.id, db=db)
@@ -102,12 +87,10 @@ async def get_folders(
@router.post('/')
async def create_folder(
request: Request,
form_data: FolderForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
form_data.parent_id, user.id, form_data.name, db=db
)
@@ -118,27 +101,6 @@ async def create_folder(
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
)
# Check if creating a subfolder in a shared folder
if form_data.parent_id:
parent = await Folders.get_folder_by_id(form_data.parent_id, db=db)
if parent and parent.user_id != user.id:
# Creating subfolder in someone else's shared folder
if user.role != 'admin' and not await _has_folder_access(user.id, parent, 'write', db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Create as the folder owner's subfolder (keep tree consistent)
try:
folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db)
return folder
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error creating folder'),
)
try:
folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db)
return folder
@@ -151,88 +113,21 @@ async def create_folder(
)
############################
# Get Shared Folders
############################
@router.get('/shared')
async def get_shared_folders(
request: Request,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get all folders shared with the current user (not owned by them)."""
await check_folders_permission(request, user, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db)
group_ids = {g.id for g in groups}
folder_perms = await Folders.get_shared_folder_ids_for_user(
user.id, group_ids, db=db
)
# Filter out folders owned by the user
results = []
owner_cache = {}
for folder_id, permission in folder_perms.items():
folder = await Folders.get_folder_by_id(folder_id, db=db)
if not folder or folder.user_id == user.id:
continue
# Get owner name (cached)
if folder.user_id not in owner_cache:
owner = await Users.get_user_by_id(folder.user_id, db=db)
owner_cache[folder.user_id] = owner.name if owner else 'Unknown'
results.append({
**folder.model_dump(),
'owner_name': owner_cache[folder.user_id],
'permission': permission,
})
# Also include child folders of shared folders (inheritance)
shared_root_ids = {r['id'] for r in results}
for root_id in list(shared_root_ids):
root_folder = await Folders.get_folder_by_id(root_id, db=db)
if root_folder:
children = await Folders.get_children_folders_by_id_and_user_id(
root_id, root_folder.user_id, db=db
)
if children:
for child in children:
if child.id not in {r['id'] for r in results}:
results.append({
**child.model_dump(),
'owner_name': owner_cache.get(child.user_id, 'Unknown'),
'permission': folder_perms.get(root_id, 'read'),
})
return results
############################
# Get Folders By Id
############################
@router.get('/{id}', response_model=None)
async def get_folder_by_id(request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
await check_folders_permission(request, user, db=db)
@router.get('/{id}', response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]}
# Check shared access
folder = await Folders.get_folder_by_id(id, db=db)
if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)):
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return folder
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
@@ -242,28 +137,17 @@ async def get_folder_by_id(request: Request, id: str, user=Depends(get_verified_
@router.post('/{id}/update')
async def update_folder_name_by_id(
request: Request,
id: str,
form_data: FolderUpdateForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if not folder:
# Check shared write access
folder = await Folders.get_folder_by_id(id, db=db)
if not folder or (user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db)):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if folder:
if form_data.name is not None:
# Check if folder with same name exists
existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, folder.user_id, form_data.name, db=db
folder.parent_id, user.id, form_data.name, db=db
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
@@ -282,7 +166,7 @@ async def update_folder_name_by_id(
)
try:
folder = await Folders.update_folder_by_id_and_user_id(id, folder.user_id, form_data, db=db)
folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db)
return folder
except Exception as e:
log.exception(e)
@@ -291,6 +175,11 @@ async def update_folder_name_by_id(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error updating folder'),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
@@ -304,13 +193,11 @@ class FolderParentIdForm(BaseModel):
@router.post('/{id}/update/parent')
async def update_folder_parent_id_by_id(
request: Request,
id: str,
form_data: FolderParentIdForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name(
@@ -351,13 +238,11 @@ class FolderIsExpandedForm(BaseModel):
@router.post('/{id}/update/expanded')
async def update_folder_is_expanded_by_id(
request: Request,
id: str,
form_data: FolderIsExpandedForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
try:
@@ -379,108 +264,6 @@ async def update_folder_is_expanded_by_id(
)
############################
# Update Folder Access By Id
############################
class FolderAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/{id}/access/update')
async def update_folder_access_by_id(
request: Request,
id: str,
form_data: FolderAccessGrantsForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id(id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Only owner, admin, or write-granted user can update access
if user.role != 'admin' and user.id != folder.user_id:
if not await _has_folder_access(user.id, folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id, user.role,
form_data.access_grants,
None,
db=db,
)
await AccessGrants.set_access_grants('folder', id, form_data.access_grants, db=db)
grants = await AccessGrants.get_grants_by_resource('folder', id, db=db)
return {
**folder.model_dump(),
'access_grants': [g.model_dump() for g in grants],
}
############################
# Get Shared Folder Chats
############################
@router.get('/{id}/shared/chats')
async def get_shared_folder_chats(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get chats within a shared folder. Returns readonly flag based on permission."""
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id(id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
is_owner = user.id == folder.user_id
is_admin = user.role == 'admin'
has_write = is_owner or is_admin or await _has_folder_access(user.id, folder, 'write', db)
has_read = has_write or await _has_folder_access(user.id, folder, 'read', db)
if not has_read:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
# Resolve owner names for display (avatar URLs are constructed client-side)
owner_cache: dict[str, str] = {}
for chat in chats:
uid = chat['user_id']
if uid not in owner_cache:
u = await Users.get_user_by_id(uid, db=db)
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
return {
'chats': [
{**chat, 'readonly': chat['user_id'] != user.id}
for chat in chats
],
'folder_permission': 'write' if has_write else 'read',
}
############################
# Delete Folder By Id
############################
@@ -494,37 +277,9 @@ async def delete_folder_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if not folder:
# Check if it's a shared subfolder with write access
folder = await Folders.get_folder_by_id(id, db=db)
if folder and folder.parent_id:
if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
elif folder and not folder.parent_id:
# Root shared folders can only be deleted by owner/admin
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
folder_owner_id = folder.user_id
folder_ids = await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder_owner_id, db=db)
if await Chats.count_chats_by_folder_ids_and_user_id(folder_ids, folder_owner_id, db=db):
if await Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db):
chat_delete_permission = await has_permission(
user.id, 'chat.delete', await Config.get('user.permissions'), db=db
user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db
)
if user.role != 'admin' and not chat_delete_permission:
raise HTTPException(
@@ -533,21 +288,18 @@ async def delete_folder_by_id(
)
folders = []
folders.append(folder)
folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db))
while folders:
folder = folders.pop()
if folder:
try:
folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, folder_owner_id, db=db)
folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db)
for folder_id in folder_ids:
if delete_contents:
await Chats.delete_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, db=db)
await Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db)
else:
await Chats.move_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, None, db=db)
# Clean up access grants for this folder
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
await Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db)
return True
except Exception as e:
@@ -559,7 +311,7 @@ async def delete_folder_by_id(
)
finally:
# Get all subfolders
subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, folder_owner_id, db=db)
subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db)
folders.extend(subfolders)
else:
+226 -199
View File
@@ -9,7 +9,6 @@ import mimetypes
import re
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
from urllib.parse import quote, urlparse
@@ -25,8 +24,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.internal.db import get_async_session
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import validate_url
from open_webui.routers.files import get_file_content_by_id, upload_file_handler
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
@@ -52,68 +50,17 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter()
IMAGE_CONFIG_KEYS = {
'ENABLE_IMAGE_GENERATION': 'image_generation.enable',
'ENABLE_IMAGE_PROMPT_GENERATION': 'image_generation.prompt.enable',
'IMAGE_GENERATION_ENGINE': 'image_generation.engine',
'IMAGE_GENERATION_MODEL': 'image_generation.model',
'IMAGE_SIZE': 'image_generation.size',
'IMAGE_STEPS': 'image_generation.steps',
'IMAGES_OPENAI_API_BASE_URL': 'image_generation.openai.api_base_url',
'IMAGES_OPENAI_API_KEY': 'image_generation.openai.api_key',
'IMAGES_OPENAI_API_VERSION': 'image_generation.openai.api_version',
'IMAGES_OPENAI_API_PARAMS': 'image_generation.openai.params',
'AUTOMATIC1111_BASE_URL': 'image_generation.automatic1111.base_url',
'AUTOMATIC1111_API_AUTH': 'image_generation.automatic1111.api_auth',
'AUTOMATIC1111_PARAMS': 'image_generation.automatic1111.api_params',
'COMFYUI_BASE_URL': 'image_generation.comfyui.base_url',
'COMFYUI_API_KEY': 'image_generation.comfyui.api_key',
'COMFYUI_WORKFLOW': 'image_generation.comfyui.workflow',
'COMFYUI_WORKFLOW_NODES': 'image_generation.comfyui.nodes',
'IMAGES_GEMINI_API_BASE_URL': 'image_generation.gemini.api_base_url',
'IMAGES_GEMINI_API_KEY': 'image_generation.gemini.api_key',
'IMAGES_GEMINI_ENDPOINT_METHOD': 'image_generation.gemini.endpoint_method',
'ENABLE_IMAGE_EDIT': 'images.edit.enable',
'IMAGE_EDIT_ENGINE': 'images.edit.engine',
'IMAGE_EDIT_MODEL': 'images.edit.model',
'IMAGE_EDIT_SIZE': 'images.edit.size',
'IMAGES_EDIT_OPENAI_API_BASE_URL': 'images.edit.openai.api_base_url',
'IMAGES_EDIT_OPENAI_API_KEY': 'images.edit.openai.api_key',
'IMAGES_EDIT_OPENAI_API_VERSION': 'images.edit.openai.api_version',
'IMAGES_EDIT_GEMINI_API_BASE_URL': 'images.edit.gemini.api_base_url',
'IMAGES_EDIT_GEMINI_API_KEY': 'images.edit.gemini.api_key',
'IMAGES_EDIT_COMFYUI_BASE_URL': 'images.edit.comfyui.base_url',
'IMAGES_EDIT_COMFYUI_API_KEY': 'images.edit.comfyui.api_key',
'IMAGES_EDIT_COMFYUI_WORKFLOW': 'images.edit.comfyui.workflow',
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': 'images.edit.comfyui.nodes',
'USER_PERMISSIONS': 'user.permissions',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
async def get_image_config() -> SimpleNamespace:
return SimpleNamespace(**await get_config_values(IMAGE_CONFIG_KEYS))
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
return {key_map[field]: value for field, value in data.items() if field in key_map}
async def set_image_model(request: Request, model: str):
log.info(f'Setting image model to {model}')
await Config.upsert({'image_generation.model': model})
image_config = await get_image_config()
if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
api_auth = get_automatic1111_api_auth(image_config)
request.app.state.config.IMAGE_GENERATION_MODEL = model
if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
api_auth = get_automatic1111_api_auth(request)
try:
session = await get_session()
async with session.get(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': api_auth},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
@@ -121,7 +68,7 @@ async def set_image_model(request: Request, model: str):
if model != options['sd_model_checkpoint']:
options['sd_model_checkpoint'] = model
async with session.post(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
json=options,
headers={'authorization': api_auth},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -130,36 +77,35 @@ async def set_image_model(request: Request, model: str):
except Exception as e:
log.debug(f'{e}')
return image_config.IMAGE_GENERATION_MODEL
return request.app.state.config.IMAGE_GENERATION_MODEL
async def get_image_model(request):
image_config = await get_image_config()
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
return (
image_config.IMAGE_GENERATION_MODEL
if image_config.IMAGE_GENERATION_MODEL
request.app.state.config.IMAGE_GENERATION_MODEL
if request.app.state.config.IMAGE_GENERATION_MODEL
else 'dall-e-2'
)
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
return (
image_config.IMAGE_GENERATION_MODEL
if image_config.IMAGE_GENERATION_MODEL
request.app.state.config.IMAGE_GENERATION_MODEL
if request.app.state.config.IMAGE_GENERATION_MODEL
else 'imagen-3.0-generate-002'
)
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
return (
image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else ''
request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL else ''
)
elif (
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or image_config.IMAGE_GENERATION_ENGINE == ''
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
try:
session = await get_session()
async with session.get(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(image_config)},
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(request)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
options = await r.json()
@@ -213,11 +159,52 @@ class ImagesConfig(BaseModel):
@router.get('/config', response_model=ImagesConfig)
async def get_config(request: Request, user=Depends(get_admin_user)):
return await get_config_values(IMAGE_CONFIG_KEYS)
return {
'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION,
'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE,
'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL,
'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE,
'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS,
'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY,
'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION,
'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS,
'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL,
'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH,
'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS,
'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL,
'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY,
'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW,
'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES,
'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY,
'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT,
'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE,
'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL,
'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE,
'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
}
@router.post('/config/update')
async def update_config(request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)):
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION
# Create Image
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if form_data.IMAGE_SIZE == 'auto' and not re.match(
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
):
@@ -229,31 +216,100 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends(
)
pattern = r'^\d+x\d+$'
if not (form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE)):
if form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE):
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 512x512).'),
)
if form_data.IMAGE_STEPS < 0:
if form_data.IMAGE_STEPS >= 0:
request.app.state.config.IMAGE_STEPS = form_data.IMAGE_STEPS
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 50).'),
)
updates = config_updates(form_data.model_dump(), IMAGE_CONFIG_KEYS)
updates['image_generation.comfyui.base_url'] = form_data.COMFYUI_BASE_URL.strip('/')
updates['images.edit.comfyui.base_url'] = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/')
await Config.upsert(updates)
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
return await get_config_values(IMAGE_CONFIG_KEYS)
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = form_data.IMAGES_OPENAI_API_BASE_URL
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY
request.app.state.config.IMAGES_OPENAI_API_VERSION = form_data.IMAGES_OPENAI_API_VERSION
request.app.state.config.IMAGES_OPENAI_API_PARAMS = form_data.IMAGES_OPENAI_API_PARAMS
request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL
request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH
request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS
request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip('/')
request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY
request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW
request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = form_data.IMAGES_GEMINI_API_BASE_URL
request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY
request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = form_data.IMAGES_GEMINI_ENDPOINT_METHOD
# Edit Image
request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT
request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE
request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL
request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE
request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = form_data.IMAGES_EDIT_OPENAI_API_BASE_URL
request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = form_data.IMAGES_EDIT_OPENAI_API_KEY
request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = form_data.IMAGES_EDIT_OPENAI_API_VERSION
request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = form_data.IMAGES_EDIT_GEMINI_API_BASE_URL
request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = form_data.IMAGES_EDIT_GEMINI_API_KEY
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/')
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = form_data.IMAGES_EDIT_COMFYUI_API_KEY
request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW
request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES
return {
'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION,
'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE,
'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL,
'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE,
'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS,
'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY,
'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION,
'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS,
'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL,
'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH,
'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS,
'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL,
'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY,
'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW,
'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES,
'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY,
'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT,
'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE,
'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL,
'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE,
'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
}
def get_automatic1111_api_auth(image_config):
if image_config.AUTOMATIC1111_API_AUTH is None:
def get_automatic1111_api_auth(request: Request):
if request.app.state.config.AUTOMATIC1111_API_AUTH is None:
return ''
else:
auth1111_byte_string = image_config.AUTOMATIC1111_API_AUTH.encode('utf-8')
auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode('utf-8')
auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string)
auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode('utf-8')
return f'Basic {auth1111_base64_encoded_string}'
@@ -261,27 +317,26 @@ def get_automatic1111_api_auth(image_config):
@router.get('/config/url/verify')
async def verify_url(request: Request, user=Depends(get_admin_user)):
image_config = await get_image_config()
if image_config.IMAGE_GENERATION_ENGINE == 'automatic1111':
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111':
try:
session = await get_session()
async with session.get(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(image_config)},
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(request)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return True
except Exception:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
headers = None
if image_config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
if request.app.state.config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
try:
session = await get_session()
async with session.get(
url=f'{image_config.COMFYUI_BASE_URL}/object_info',
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
@@ -295,34 +350,33 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
@router.get('/models')
async def get_models(request: Request, user=Depends(get_verified_user)):
image_config = await get_image_config()
try:
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
return [
{'id': 'dall-e-2', 'name': 'DALL·E 2'},
{'id': 'dall-e-3', 'name': 'DALL·E 3'},
{'id': 'gpt-image-1', 'name': 'GPT-IMAGE 1'},
{'id': 'gpt-image-1.5', 'name': 'GPT-IMAGE 1.5'},
]
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
return [
{'id': 'imagen-3.0-generate-002', 'name': 'imagen-3.0 generate-002'},
]
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
# TODO - get models from comfyui
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
session = await get_session()
async with session.get(
url=f'{image_config.COMFYUI_BASE_URL}/object_info',
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
info = await r.json()
workflow = json.loads(image_config.COMFYUI_WORKFLOW)
workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW)
model_node_id = None
for node in image_config.COMFYUI_WORKFLOW_NODES:
for node in request.app.state.config.COMFYUI_WORKFLOW_NODES:
if node['type'] == 'model':
if node['node_ids']:
model_node_id = node['node_ids'][0]
@@ -352,13 +406,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
)
)
elif (
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or image_config.IMAGE_GENERATION_ENGINE == ''
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
session = await get_session()
async with session.get(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
headers={'authorization': get_automatic1111_api_auth(image_config)},
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
headers={'authorization': get_automatic1111_api_auth(request)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
models = await r.json()
@@ -486,15 +540,14 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non
@router.post('/generations')
async def generate_images(request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)):
image_config = await get_image_config()
if not image_config.ENABLE_IMAGE_GENERATION:
if not request.app.state.config.ENABLE_IMAGE_GENERATION:
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.image_generation', image_config.USER_PERMISSIONS
user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=403,
@@ -510,14 +563,13 @@ async def image_generations(
metadata: dict | None = None,
user=None,
):
image_config = await get_image_config()
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
# This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
# image model other than gpt-image-1, which is warned about on settings save
size = '512x512'
if image_config.IMAGE_SIZE and 'x' in image_config.IMAGE_SIZE:
size = image_config.IMAGE_SIZE
if request.app.state.config.IMAGE_SIZE and 'x' in request.app.state.config.IMAGE_SIZE:
size = request.app.state.config.IMAGE_SIZE
if form_data.size and 'x' in form_data.size:
size = form_data.size
@@ -529,40 +581,40 @@ async def image_generations(
model = await get_image_model(request)
try:
if image_config.IMAGE_GENERATION_ENGINE == 'openai':
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
headers = {
'Authorization': f'Bearer {image_config.IMAGES_OPENAI_API_KEY}',
'Authorization': f'Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}',
'Content-Type': 'application/json',
}
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
url = f'{image_config.IMAGES_OPENAI_API_BASE_URL}/images/generations'
if image_config.IMAGES_OPENAI_API_VERSION:
url = f'{url}?api-version={image_config.IMAGES_OPENAI_API_VERSION}'
url = f'{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations'
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
url = f'{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}'
data = {
'model': model,
'prompt': form_data.prompt,
'n': form_data.n,
**(
{'size': form_data.size or image_config.IMAGE_SIZE}
if (form_data.size or image_config.IMAGE_SIZE)
{'size': form_data.size or request.app.state.config.IMAGE_SIZE}
if (form_data.size or request.app.state.config.IMAGE_SIZE)
else {}
),
**(
{}
if re.match(
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
image_config.IMAGE_GENERATION_MODEL,
request.app.state.config.IMAGE_GENERATION_MODEL,
)
else {'response_format': 'b64_json'}
),
**(
{}
if not image_config.IMAGES_OPENAI_API_PARAMS
else image_config.IMAGES_OPENAI_API_PARAMS
if not request.app.state.config.IMAGES_OPENAI_API_PARAMS
else request.app.state.config.IMAGES_OPENAI_API_PARAMS
),
}
@@ -591,17 +643,17 @@ async def image_generations(
images.append({'url': url})
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini':
headers = {
'Content-Type': 'application/json',
'x-goog-api-key': image_config.IMAGES_GEMINI_API_KEY,
'x-goog-api-key': request.app.state.config.IMAGES_GEMINI_API_KEY,
}
data = {}
if (
image_config.IMAGES_GEMINI_ENDPOINT_METHOD == ''
or image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict'
request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == ''
or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict'
):
model = f'{model}:predict'
data = {
@@ -612,13 +664,13 @@ async def image_generations(
},
}
elif image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent':
elif request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent':
model = f'{model}:generateContent'
data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
session = await get_session()
async with session.post(
url=f'{image_config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
json=data,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -649,7 +701,7 @@ async def image_generations(
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui':
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
data = {
'prompt': form_data.prompt,
'width': width,
@@ -657,8 +709,8 @@ async def image_generations(
'n': form_data.n,
}
if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None:
data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS
if form_data.negative_prompt is not None:
data['negative_prompt'] = form_data.negative_prompt
@@ -667,8 +719,8 @@ async def image_generations(
**{
'workflow': ComfyUIWorkflow(
**{
'workflow': image_config.COMFYUI_WORKFLOW,
'nodes': image_config.COMFYUI_WORKFLOW_NODES,
'workflow': request.app.state.config.COMFYUI_WORKFLOW,
'nodes': request.app.state.config.COMFYUI_WORKFLOW_NODES,
}
),
**data,
@@ -678,8 +730,8 @@ async def image_generations(
model,
form_data,
str(uuid.uuid4()),
image_config.COMFYUI_BASE_URL,
image_config.COMFYUI_API_KEY,
request.app.state.config.COMFYUI_BASE_URL,
request.app.state.config.COMFYUI_API_KEY,
)
log.debug(f'res: {res}')
@@ -687,13 +739,13 @@ async def image_generations(
for image in res['data']:
headers = None
if image_config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
if request.app.state.config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
image_data, content_type = await get_image_data(
image['url'],
headers,
trusted_base_url=image_config.COMFYUI_BASE_URL,
trusted_base_url=request.app.state.config.COMFYUI_BASE_URL,
)
_, url = await upload_image(
request,
@@ -705,8 +757,8 @@ async def image_generations(
images.append({'url': url})
return images
elif (
image_config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or image_config.IMAGE_GENERATION_ENGINE == ''
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
if form_data.model:
await set_image_model(request, form_data.model)
@@ -718,20 +770,20 @@ async def image_generations(
'height': height,
}
if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None:
data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS
if form_data.negative_prompt is not None:
data['negative_prompt'] = form_data.negative_prompt
if image_config.AUTOMATIC1111_PARAMS:
data = {**data, **image_config.AUTOMATIC1111_PARAMS}
if request.app.state.config.AUTOMATIC1111_PARAMS:
data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS}
session = await get_session()
async with session.post(
url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
json=data,
headers={'authorization': get_automatic1111_api_auth(image_config)},
headers={'authorization': get_automatic1111_api_auth(request)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
res = await r.json(content_type=None)
@@ -768,46 +820,23 @@ class EditImageForm(BaseModel):
@router.post('/edit')
async def edit_images(request: Request, form_data: EditImageForm, user=Depends(get_verified_user)):
# Authorize the direct route like /generations and the edit_image tool: enforce the
# global image-edit switch and the per-user image-generation permission. The internal
# callers (edit_image tool, chat middleware) gate themselves and call image_edits()
# directly, so they are unaffected by this wrapper.
if not request.app.state.config.ENABLE_IMAGE_EDIT:
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return await image_edits(request, form_data, user=user)
async def image_edits(
request: Request,
form_data: EditImageForm,
metadata: dict | None = None,
user=Depends(get_verified_user),
):
image_config = await get_image_config()
size = None
width, height = None, None
metadata = metadata or {}
if (image_config.IMAGE_EDIT_SIZE and 'x' in image_config.IMAGE_EDIT_SIZE) or (
if (request.app.state.config.IMAGE_EDIT_SIZE and 'x' in request.app.state.config.IMAGE_EDIT_SIZE) or (
form_data.size and 'x' in form_data.size
):
size = form_data.size if form_data.size else image_config.IMAGE_EDIT_SIZE
size = form_data.size if form_data.size else request.app.state.config.IMAGE_EDIT_SIZE
width, height = tuple(map(int, size.split('x')))
model = image_config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model
model = request.app.state.config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model
try:
@@ -822,16 +851,14 @@ async def image_edits(
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(data)
# SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer
# that passed validate_url cannot reach an internal address.
async with get_ssrf_safe_session() as session:
async with session.get(
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as r:
r.raise_for_status()
session = await get_session()
async with session.get(
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as r:
r.raise_for_status()
image_data = base64.b64encode(await r.read()).decode('utf-8')
return f'data:{r.headers["content-type"]};base64,{image_data}'
image_data = base64.b64encode(await r.read()).decode('utf-8')
return f'data:{r.headers["content-type"]};base64,{image_data}'
else:
file_id = None
@@ -876,9 +903,9 @@ async def image_edits(
)
try:
if image_config.IMAGE_EDIT_ENGINE == 'openai':
if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai':
headers = {
'Authorization': f'Bearer {image_config.IMAGES_EDIT_OPENAI_API_KEY}',
'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS:
@@ -894,7 +921,7 @@ async def image_edits(
{}
if re.match(
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
image_config.IMAGE_EDIT_MODEL,
request.app.state.config.IMAGE_EDIT_MODEL,
)
else {'response_format': 'b64_json'}
),
@@ -908,8 +935,8 @@ async def image_edits(
files.append(get_image_file_item(img, 'image[]'))
url_search_params = ''
if image_config.IMAGES_EDIT_OPENAI_API_VERSION:
url_search_params += f'?api-version={image_config.IMAGES_EDIT_OPENAI_API_VERSION}'
if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION:
url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}'
# Build multipart form data for aiohttp
form = aiohttp.FormData()
@@ -928,7 +955,7 @@ async def image_edits(
session = await get_session()
async with session.post(
url=f'{image_config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
headers=headers,
data=form,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -950,10 +977,10 @@ async def image_edits(
images.append({'url': url})
return images
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
elif request.app.state.config.IMAGE_EDIT_ENGINE == 'gemini':
headers = {
'Content-Type': 'application/json',
'x-goog-api-key': image_config.IMAGES_EDIT_GEMINI_API_KEY,
'x-goog-api-key': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
}
model = f'{model}:generateContent'
@@ -983,7 +1010,7 @@ async def image_edits(
session = await get_session()
async with session.post(
url=f'{image_config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
json=data,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
@@ -1007,7 +1034,7 @@ async def image_edits(
return images
elif image_config.IMAGE_EDIT_ENGINE == 'comfyui':
elif request.app.state.config.IMAGE_EDIT_ENGINE == 'comfyui':
try:
files = []
if isinstance(form_data.image, str):
@@ -1021,8 +1048,8 @@ async def image_edits(
for file_item in files:
res = await comfyui_upload_image(
file_item,
image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
image_config.IMAGES_EDIT_COMFYUI_API_KEY,
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
)
comfyui_images.append(res.get('name', file_item[1][0]))
except Exception as e:
@@ -1041,8 +1068,8 @@ async def image_edits(
**{
'workflow': ComfyUIWorkflow(
**{
'workflow': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW,
'nodes': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
'workflow': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
'nodes': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
}
),
**data,
@@ -1052,8 +1079,8 @@ async def image_edits(
model,
form_data,
str(uuid.uuid4()),
image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
image_config.IMAGES_EDIT_COMFYUI_API_KEY,
request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
)
log.debug(f'res: {res}')
@@ -1072,13 +1099,13 @@ async def image_edits(
for image_url in image_urls:
headers = None
if image_config.IMAGES_EDIT_COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {image_config.IMAGES_EDIT_COMFYUI_API_KEY}'}
if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'}
image_data, content_type = await get_image_data(
image_url,
headers,
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
trusted_base_url=request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
)
_, url = await upload_image(
request,
+4 -5
View File
@@ -13,7 +13,6 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files
from open_webui.models.groups import Groups
from open_webui.models.knowledge import (
@@ -258,7 +257,7 @@ async def create_new_knowledge(
# This prevents holding a connection during embed_knowledge_base_metadata()
# which makes external embedding API calls (1-5+ seconds).
if user.role != 'admin' and not await has_permission(
user.id, 'workspace.knowledge', await Config.get('user.permissions')
user.id, 'workspace.knowledge', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -266,7 +265,7 @@ async def create_new_knowledge(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -470,7 +469,7 @@ async def update_knowledge_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -538,7 +537,7 @@ async def update_knowledge_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
+78 -26
View File
@@ -7,7 +7,6 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
@@ -21,23 +20,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
async def check_memories_permission(user):
config = await Config.get_many('memories.enable', 'user.permissions')
if not config.get('memories.enable'):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.memories', config.get('user.permissions')
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
############################
# GetMemories
# Let what is remembered here spare someone the cost
@@ -51,7 +33,17 @@ async def get_memories(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return await Memories.get_memories_by_user_id(user.id, db=db)
@@ -81,7 +73,17 @@ async def add_memory(
own short-lived sessions so a connection is not held during the external
embedding API call (``EMBEDDING_FUNCTION``), which can take 1-5+ seconds.
"""
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = await Memories.insert_new_memory(user.id, form_data.content)
@@ -122,7 +124,17 @@ async def query_memory(
# Database operations (get_memories_by_user_id) manage their own short-lived sessions.
# This prevents holding a connection during EMBEDDING_FUNCTION()
# which makes external embedding API calls (1-5+ seconds).
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memories = await Memories.get_memories_by_user_id(user.id)
if not memories:
@@ -142,7 +154,7 @@ async def query_memory(
# same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching
# memories are surfaced (distances are normalised to 0→1, higher is
# better).
relevance_threshold = await Config.get('rag.relevance_threshold', 0.0)
relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0)
if results and relevance_threshold > 0.0 and results.distances and results.distances[0]:
from open_webui.retrieval.vector.main import SearchResult
@@ -187,7 +199,17 @@ async def reset_memory_from_vector_db(
calls simultaneously. With a session held, this could block a connection
for MINUTES, completely exhausting the connection pool.
"""
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
@@ -228,7 +250,17 @@ async def delete_memory_by_user_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = await Memories.delete_memories_by_user_id(user.id, db=db)
@@ -258,7 +290,17 @@ async def update_memory_by_id(
# Database operations (update_memory_by_id_and_user_id) manage their own
# short-lived sessions. This prevents holding a connection during
# EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds).
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content)
if memory is None:
@@ -297,7 +339,17 @@ async def delete_memory_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_memories_permission(user)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
+8 -12
View File
@@ -23,7 +23,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.models import (
ModelAccessListResponse,
@@ -61,9 +60,6 @@ def _safe_static_redirect_path(url: str) -> str | None:
if decoded == path:
break
path = decoded
# Fail closed: a value still encoded after the cap would be decoded further downstream.
if unquote(path) != path:
return None
if '\x00' in path or '\\' in path:
return None
if not path.startswith('/'):
@@ -231,7 +227,7 @@ async def create_new_model(
):
"""Create a new workspace model entry."""
if user.role != 'admin' and not await has_permission(
user.id, 'workspace.models', await Config.get('user.permissions'), db=db
user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -259,7 +255,7 @@ async def create_new_model(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -290,7 +286,7 @@ async def export_models(
if user.role != 'admin' and not await has_permission(
user.id,
'workspace.models_export',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
@@ -323,7 +319,7 @@ async def import_models(
if user.role != 'admin' and not await has_permission(
user.id,
'workspace.models_import',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
@@ -404,7 +400,7 @@ async def import_models(
# metadata-only imports.
if 'access_grants' in model_data:
updated_model.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
updated_model.access_grants,
@@ -417,7 +413,7 @@ async def import_models(
model_data['params'] = model_data.get('params', {})
new_model = ModelForm(**model_data)
new_model.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
new_model.access_grants,
@@ -678,7 +674,7 @@ async def update_model_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -750,7 +746,7 @@ async def update_model_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
+12 -13
View File
@@ -11,7 +11,6 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.notes import (
NoteForm,
@@ -67,7 +66,7 @@ async def get_notes(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -115,7 +114,7 @@ async def get_pinned_notes(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -156,7 +155,7 @@ async def search_notes(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -209,7 +208,7 @@ async def create_new_note(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -217,7 +216,7 @@ async def create_new_note(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -250,7 +249,7 @@ async def get_note_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -309,7 +308,7 @@ async def update_note_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -333,7 +332,7 @@ async def update_note_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -376,7 +375,7 @@ async def update_note_access_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -400,7 +399,7 @@ async def update_note_access_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -428,7 +427,7 @@ async def pin_note_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -470,7 +469,7 @@ async def delete_note_by_id(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+92 -114
View File
@@ -31,7 +31,6 @@ from open_webui.env import (
)
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.models import Models
from open_webui.models.users import UserModel
@@ -182,32 +181,6 @@ def get_api_key(idx, url, configs):
router = APIRouter()
OLLAMA_CONFIG_KEYS = {
'ENABLE_OLLAMA_API': 'ollama.enable',
'OLLAMA_BASE_URLS': 'ollama.base_urls',
'OLLAMA_API_CONFIGS': 'ollama.api_configs',
}
async def get_ollama_config_values() -> dict:
values = await Config.get_many(*OLLAMA_CONFIG_KEYS.values())
return {field: values[storage_key] for field, storage_key in OLLAMA_CONFIG_KEYS.items() if storage_key in values}
async def get_ollama_runtime_config() -> tuple[bool, list[str], dict]:
values = await Config.get_many('ollama.enable', 'ollama.base_urls', 'ollama.api_configs')
return (
values.get('ollama.enable'),
values.get('ollama.base_urls') or [],
values.get('ollama.api_configs') or {},
)
async def get_ollama_connection(idx: int) -> tuple[str, dict, str | None]:
_, base_urls, api_configs = await get_ollama_runtime_config()
url = base_urls[idx]
return url, resolve_api_config(api_configs, idx, url), get_api_key(idx, url, api_configs)
@router.head('/')
@router.get('/')
@@ -263,7 +236,11 @@ async def get_config(
user=Depends(get_admin_user),
) -> dict:
"""Return the current Ollama connection configuration."""
return await get_ollama_config_values()
return {
'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API,
'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS,
'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS,
}
class OllamaConfigForm(BaseModel):
@@ -281,20 +258,20 @@ async def update_config(
user=Depends(get_admin_user),
) -> dict:
"""Persist updated Ollama connection settings."""
valid_keys = {str(i) for i in range(len(form_data.OLLAMA_BASE_URLS))}
api_configs = {k: v for k, v in form_data.OLLAMA_API_CONFIGS.items() if k in valid_keys}
request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
# Prune stale config entries that no longer map to a URL index
valid_keys = {str(i) for i in range(len(request.app.state.config.OLLAMA_BASE_URLS))}
request.app.state.config.OLLAMA_API_CONFIGS = {
k: v for k, v in request.app.state.config.OLLAMA_API_CONFIGS.items() if k in valid_keys
}
await Config.upsert(
{
'ollama.enable': form_data.ENABLE_OLLAMA_API,
'ollama.base_urls': form_data.OLLAMA_BASE_URLS,
'ollama.api_configs': api_configs,
}
)
return {
'ENABLE_OLLAMA_API': form_data.ENABLE_OLLAMA_API,
'OLLAMA_BASE_URLS': form_data.OLLAMA_BASE_URLS,
'OLLAMA_API_CONFIGS': api_configs,
'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API,
'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS,
'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS,
}
@@ -316,8 +293,9 @@ def merge_models_lists(model_lists) -> list[dict]:
return list(merged.values())
def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
def _resolve_api_config(request: Request, idx: int, url: str) -> dict:
"""Look up the API config for a backend by numeric index, falling back to URL key (legacy)."""
api_configs = request.app.state.config.OLLAMA_API_CONFIGS
return api_configs.get(str(idx), api_configs.get(url, {}))
@@ -329,15 +307,15 @@ async def get_all_models(request: Request, user: UserModel | None = None):
"""Aggregate model tags from every enabled Ollama backend."""
log.info('get_all_models()')
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
models_dict: dict = {'models': []}
request.app.state.OLLAMA_MODELS = {}
return models_dict
# Fan-out tag requests to every backend
tasks = []
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = _resolve_api_config(request, idx, url)
if not api_config:
tasks.append(send_get_request(f'{url}/api/tags', user=user))
elif api_config.get('enable', True):
@@ -351,8 +329,8 @@ async def get_all_models(request: Request, user: UserModel | None = None):
for idx, response in enumerate(responses):
if not response:
continue
url = (await Config.get('ollama.base_urls', []))[idx]
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = _resolve_api_config(request, idx, url)
connection_type = api_config.get('connection_type', 'local')
prefix_id = api_config.get('prefix_id')
@@ -416,14 +394,14 @@ async def get_ollama_tags(
user=Depends(get_verified_user),
):
"""List Ollama model tags, optionally from a specific backend."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
result = await get_all_models(request, user=user)
else:
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
result = await send_request(f'{url}/api/tags', 'GET', key=key, user=user)
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
@@ -438,12 +416,12 @@ async def get_ollama_loaded_models(
user=Depends(get_admin_user),
) -> dict:
"""List models currently loaded in Ollama memory across all backends."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
return {'models': []}
tasks = []
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, url)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = _resolve_api_config(request, idx, url)
if not api_config:
tasks.append(send_get_request(f'{url}/api/ps', user=user))
elif api_config.get('enable', True):
@@ -456,7 +434,7 @@ async def get_ollama_loaded_models(
for idx, response in enumerate(responses):
if not response:
continue
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), idx, (await Config.get('ollama.base_urls', []))[idx])
api_config = _resolve_api_config(request.app.state.config, idx, request.app.state.config.OLLAMA_BASE_URLS[idx])
prefix_id = api_config.get('prefix_id')
if prefix_id:
for m in response.get('models', []):
@@ -472,19 +450,19 @@ async def get_ollama_versions(
url_idx: int | None = None,
):
"""Return the lowest Ollama version across all configured backends."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
return {'version': False}
if url_idx is not None:
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return await send_request(f'{url}/api/version', 'GET')
# Fan-out to every enabled backend
tasks = []
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
api_config = (await Config.get('ollama.api_configs', {})).get(
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
if api_config.get('enable', True):
tasks.append(send_get_request(f'{url}/api/version', api_config.get('key')))
@@ -533,11 +511,11 @@ async def unload_model(
results = []
errors = []
for idx in url_indices:
url = (await Config.get('ollama.base_urls', []))[idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(idx), (await Config.get('ollama.api_configs', {})).get(url, {})
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 = get_api_key(idx, url, (await Config.get('ollama.api_configs', {})))
key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get('prefix_id', None)
if prefix_id and model.startswith(f'{prefix_id}.'):
@@ -574,20 +552,20 @@ async def pull_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
form_data = form_data.model_dump(exclude_none=True)
form_data['model'] = form_data.get('model', form_data.get('name'))
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
log.info(f'url: {url}')
# Admins may pull from any registry
return await send_request(
f'{url}/api/pull',
payload=json.dumps({**form_data, 'insecure': True}),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=True,
)
@@ -610,7 +588,7 @@ async def push_model(
user=Depends(get_admin_user),
):
"""Push a local model to a remote registry."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
@@ -620,13 +598,13 @@ async def push_model(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = models[form_data.model]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
log.debug(f'url: {url}')
return await send_request(
f'{url}/api/push',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=True,
)
@@ -649,16 +627,16 @@ async def create_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.debug(f'form_data: {form_data}')
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return await send_request(
f'{url}/api/create',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=True,
)
@@ -680,7 +658,7 @@ async def copy_model(
user=Depends(get_admin_user),
):
"""Duplicate an existing model under a new name."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
@@ -690,8 +668,8 @@ async def copy_model(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source))
url_idx = models[form_data.source]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
await send_request(
f'{url}/api/copy',
@@ -711,7 +689,7 @@ async def delete_model(
user=Depends(get_admin_user),
):
"""Remove a model from an Ollama backend."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump(exclude_none=True)
@@ -725,8 +703,8 @@ async def delete_model(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
url_idx = models[model]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
await send_request(
f'{url}/api/delete',
@@ -745,7 +723,7 @@ async def show_model_info(
user=Depends(get_verified_user),
):
"""Retrieve model metadata from the Ollama backend."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump(exclude_none=True)
@@ -761,8 +739,8 @@ async def show_model_info(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
return await send_request(
f'{url}/api/show',
@@ -792,7 +770,7 @@ async def embed(
user=Depends(get_verified_user),
):
"""Generate embeddings via the Ollama /api/embed endpoint."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_batch_embeddings {form_data}')
@@ -809,12 +787,12 @@ async def embed(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@@ -846,7 +824,7 @@ async def embeddings(
user=Depends(get_verified_user),
):
"""Generate embeddings via the legacy Ollama /api/embeddings endpoint."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_embeddings {form_data}')
@@ -863,12 +841,12 @@ async def embeddings(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@@ -908,7 +886,7 @@ async def generate_completion(
user=Depends(get_verified_user),
):
"""Run text completion via Ollama /api/generate."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
@@ -922,10 +900,10 @@ async def generate_completion(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
)
prefix_id = api_config.get('prefix_id')
@@ -935,7 +913,7 @@ async def generate_completion(
return await send_request(
f'{url}/api/generate',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=True,
)
@@ -995,7 +973,7 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
)
url_idx = random.choice(models[model].get('urls', []))
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return url, url_idx
@@ -1008,7 +986,7 @@ async def generate_chat_completion(
user=Depends(get_verified_user), # noqa: B008
):
"""Forward a chat completion request to an Ollama backend."""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
@@ -1057,7 +1035,7 @@ async def generate_chat_completion(
await check_model_access(user, None, bypass_filter)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_config = _resolve_api_config(request, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@@ -1066,7 +1044,7 @@ async def generate_chat_completion(
return await send_request(
f'{url}/api/chat',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=form_data.stream,
content_type='application/x-ndjson',
@@ -1143,7 +1121,7 @@ async def generate_openai_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_config = _resolve_api_config(request, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@@ -1152,7 +1130,7 @@ async def generate_openai_completion(
return await send_request(
f'{url}/v1/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
@@ -1200,7 +1178,7 @@ async def generate_openai_chat_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_config = _resolve_api_config(request, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
@@ -1209,7 +1187,7 @@ async def generate_openai_chat_completion(
return await send_request(
f'{url}/v1/chat/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
@@ -1233,7 +1211,7 @@ async def generate_anthropic_messages(
See https://docs.ollama.com/api/anthropic-compatibility
"""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = {**form_data}
@@ -1249,9 +1227,9 @@ async def generate_anthropic_messages(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get('prefix_id', None)
@@ -1261,7 +1239,7 @@ async def generate_anthropic_messages(
return await send_request(
f'{url}/v1/messages',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,
@@ -1291,7 +1269,7 @@ async def generate_responses(
See https://ollama.com/blog/responses-api
"""
if not await Config.get('ollama.enable'):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump()
@@ -1307,9 +1285,9 @@ async def generate_responses(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get('prefix_id', None)
@@ -1319,7 +1297,7 @@ async def generate_responses(
return await send_request(
f'{url}/v1/responses',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,
@@ -1339,7 +1317,7 @@ async def get_openai_models(
model_list = await get_all_models(request, user=user)
raw_models = model_list['models']
else:
url = (await Config.get('ollama.base_urls', []))[url_idx]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
model_list = await send_request(f'{url}/api/tags', 'GET')
raw_models = model_list.get('models', [])
@@ -1451,7 +1429,7 @@ async def download_model(
detail='Invalid file_url. Only URLs from allowed hosts are permitted.',
)
url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0]
file_name = parse_huggingface_url(form_data.url)
if not file_name:
@@ -1472,7 +1450,7 @@ async def upload_model(
user=Depends(get_admin_user),
):
"""Upload a local model file, push it as a blob, and create the model in Ollama."""
ollama_url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx if url_idx is not None else 0]
filename = os.path.basename(file.filename)
file_path = os.path.join(UPLOAD_DIR, filename)
+97 -84
View File
@@ -34,11 +34,10 @@ from open_webui.env import (
)
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.utils.access_control import check_model_access, has_connection_access, has_permission
from open_webui.utils.access_control import check_model_access, has_connection_access
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
@@ -237,50 +236,15 @@ def get_microsoft_entra_id_access_token():
router = APIRouter()
OPENAI_CONFIG_KEYS = {
'ENABLE_OPENAI_API': 'openai.enable',
'OPENAI_API_BASE_URLS': 'openai.api_base_urls',
'OPENAI_API_KEYS': 'openai.api_keys',
'OPENAI_API_CONFIGS': 'openai.api_configs',
}
async def get_openai_config() -> dict:
values = await Config.get_many(*OPENAI_CONFIG_KEYS.values())
return {field: values[storage_key] for field, storage_key in OPENAI_CONFIG_KEYS.items() if storage_key in values}
async def get_openai_runtime_config() -> tuple[bool, list[str], list[str], dict]:
values = await Config.get_many('openai.enable', 'openai.api_base_urls', 'openai.api_keys', 'openai.api_configs')
return (
values.get('openai.enable'),
values.get('openai.api_base_urls') or [],
values.get('openai.api_keys') or [],
values.get('openai.api_configs') or {},
)
async def normalize_openai_api_keys(api_base_urls: list[str], api_keys: list[str]) -> list[str]:
if len(api_keys) > len(api_base_urls):
api_keys = api_keys[: len(api_base_urls)]
elif len(api_keys) < len(api_base_urls):
api_keys = [*api_keys, *([''] * (len(api_base_urls) - len(api_keys)))]
await Config.upsert({'openai.api_keys': api_keys})
return api_keys
async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
_, api_base_urls, api_keys, api_configs = await get_openai_runtime_config()
url = api_base_urls[idx]
key = api_keys[idx]
api_config = api_configs.get(str(idx), api_configs.get(url, {}))
return url, key, api_config
@router.get('/config')
async def get_config(request: Request, user=Depends(get_admin_user)):
return await get_openai_config()
return {
'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API,
'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS,
'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS,
'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS,
}
class OpenAIConfigForm(BaseModel):
@@ -292,47 +256,42 @@ class OpenAIConfigForm(BaseModel):
@router.post('/config/update')
async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depends(get_admin_user)):
api_keys = form_data.OPENAI_API_KEYS
request.app.state.config.ENABLE_OPENAI_API = form_data.ENABLE_OPENAI_API
request.app.state.config.OPENAI_API_BASE_URLS = form_data.OPENAI_API_BASE_URLS
request.app.state.config.OPENAI_API_KEYS = form_data.OPENAI_API_KEYS
if len(api_keys) > len(form_data.OPENAI_API_BASE_URLS):
api_keys = api_keys[: len(form_data.OPENAI_API_BASE_URLS)]
elif len(api_keys) < len(form_data.OPENAI_API_BASE_URLS):
api_keys = [*api_keys, *([''] * (len(form_data.OPENAI_API_BASE_URLS) - len(api_keys)))]
# Check if API KEYS length is same than API URLS length
if len(request.app.state.config.OPENAI_API_KEYS) != len(request.app.state.config.OPENAI_API_BASE_URLS):
if len(request.app.state.config.OPENAI_API_KEYS) > len(request.app.state.config.OPENAI_API_BASE_URLS):
request.app.state.config.OPENAI_API_KEYS = request.app.state.config.OPENAI_API_KEYS[
: len(request.app.state.config.OPENAI_API_BASE_URLS)
]
else:
request.app.state.config.OPENAI_API_KEYS += [''] * (
len(request.app.state.config.OPENAI_API_BASE_URLS) - len(request.app.state.config.OPENAI_API_KEYS)
)
valid_keys = set(map(str, range(len(form_data.OPENAI_API_BASE_URLS))))
api_configs = {key: value for key, value in form_data.OPENAI_API_CONFIGS.items() if key in valid_keys}
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
await Config.upsert(
{
'openai.enable': form_data.ENABLE_OPENAI_API,
'openai.api_base_urls': form_data.OPENAI_API_BASE_URLS,
'openai.api_keys': api_keys,
'openai.api_configs': api_configs,
}
)
# Remove the API configs that are not in the API URLS
keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
request.app.state.config.OPENAI_API_CONFIGS = {
key: value for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() if key in keys
}
return {
'ENABLE_OPENAI_API': form_data.ENABLE_OPENAI_API,
'OPENAI_API_BASE_URLS': form_data.OPENAI_API_BASE_URLS,
'OPENAI_API_KEYS': api_keys,
'OPENAI_API_CONFIGS': api_configs,
'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API,
'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS,
'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS,
'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS,
}
@router.post('/audio/speech')
async def speech(request: Request, user=Depends(get_verified_user)):
if user.role != 'admin' and not await has_permission(
user.id, 'chat.tts', await Config.get('user.permissions')
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
idx = None
try:
_, api_base_urls, _, _ = await get_openai_runtime_config()
idx = api_base_urls.index('https://api.openai.com/v1')
idx = request.app.state.config.OPENAI_API_BASE_URLS.index('https://api.openai.com/v1')
body = await request.body()
name = hashlib.sha256(body).hexdigest()
@@ -346,7 +305,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
if file_path.is_file():
return FileResponse(file_path)
url, key, api_config = await get_openai_connection(idx)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
@@ -396,15 +360,29 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async def get_all_models_responses(request: Request, user: UserModel) -> list:
enable_openai_api, api_base_urls, api_keys, api_configs = await get_openai_runtime_config()
if not enable_openai_api:
if not request.app.state.config.ENABLE_OPENAI_API:
return []
# Cache config values locally to avoid repeated Redis lookups.
# Each access to request.app.state.config.<KEY> triggers a Redis GET;
# caching here avoids hundreds of redundant round-trips.
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
api_keys = list(request.app.state.config.OPENAI_API_KEYS)
api_configs = request.app.state.config.OPENAI_API_CONFIGS
# Check if API KEYS length is same than API URLS length
num_urls = len(api_base_urls)
num_keys = len(api_keys)
if num_keys != num_urls:
api_keys = await normalize_openai_api_keys(api_base_urls, api_keys)
# if there are more keys than urls, remove the extra keys
if num_keys > num_urls:
api_keys = api_keys[:num_urls]
request.app.state.config.OPENAI_API_KEYS = api_keys
# if there are more urls than keys, add empty keys
else:
api_keys += [''] * (num_urls - num_keys)
request.app.state.config.OPENAI_API_KEYS = api_keys
request_tasks = []
for idx, url in enumerate(api_base_urls):
@@ -514,10 +492,13 @@ async def get_filtered_models(models, user, db=None):
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info('get_all_models()')
enable_openai_api, api_base_urls, _, _ = await get_openai_runtime_config()
if not enable_openai_api:
if not request.app.state.config.ENABLE_OPENAI_API:
return {'data': []}
# Cache config value locally to avoid repeated Redis lookups inside
# the nested loop in get_merged_models (one GET per model otherwise).
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
responses = await get_all_models_responses(request, user=user)
def extract_data(response):
@@ -588,7 +569,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
@router.get('/models')
@router.get('/models/{url_idx}')
async def get_models(request: Request, url_idx: int | None = None, user=Depends(get_verified_user)):
if not await Config.get('openai.enable'):
if not request.app.state.config.ENABLE_OPENAI_API:
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
models = {
@@ -598,7 +579,13 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
if url_idx is None:
models = await get_all_models(request, user=user)
else:
url, key, api_config = await get_openai_connection(url_idx)
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
key = request.app.state.config.OPENAI_API_KEYS[url_idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
async with aiohttp.ClientSession(
@@ -1127,7 +1114,13 @@ async def generate_chat_completion(
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
)
url, key, api_config = await get_openai_connection(idx)
# Get the API config for the model
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
)
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
@@ -1142,6 +1135,9 @@ async def generate_chat_completion(
'role': user.role,
}
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Check if model is a reasoning model that needs special handling
if is_openai_new_model(payload['model']):
payload = openai_reasoning_model_handler(payload)
@@ -1307,7 +1303,12 @@ async def embeddings(request: Request, form_data: dict, user):
if model_id in models:
idx = models[model_id]['urlIdx']
url, key, api_config = await get_openai_connection(idx)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
streaming = False
@@ -1425,7 +1426,12 @@ async def responses(
if model_id in models:
idx = models[model_id]['urlIdx']
url, key, api_config = await get_openai_connection(idx)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
streaming = False
@@ -1529,7 +1535,14 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
if model_id in models:
idx = models[model_id]['urlIdx']
url, key, api_config = await get_openai_connection(idx)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
)
r = None
streaming = False
+19 -18
View File
@@ -18,7 +18,6 @@ from fastapi import (
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.models.config import Config
from open_webui.routers.openai import get_all_models_responses
from open_webui.utils.auth import get_admin_user
from pydantic import BaseModel
@@ -52,12 +51,6 @@ def get_sorted_filters(model_id, models):
return sorted_filters
async def get_openai_connection(url_idx: int) -> tuple[str, str]:
base_urls = await Config.get('openai.api_base_urls', [])
api_keys = await Config.get('openai.api_keys', [])
return base_urls[url_idx], api_keys[url_idx]
async def process_pipeline_inlet_filter(request, payload, user, models):
user = {'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role}
model_id = payload['model']
@@ -76,7 +69,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
except Exception:
continue
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
if not key:
continue
@@ -139,7 +133,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
except Exception:
continue
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
if not key:
continue
@@ -199,12 +194,11 @@ async def get_pipelines_list(request: Request, user=Depends(get_admin_user)):
log.debug(f'get_pipelines_list: get_openai_models_responses returned {responses}')
urlIdxs = [idx for idx, response in enumerate(responses) if response is not None and 'pipelines' in response]
base_urls = await Config.get('openai.api_base_urls', [])
return {
'data': [
{
'url': base_urls[urlIdx],
'url': request.app.state.config.OPENAI_API_BASE_URLS[urlIdx],
'idx': urlIdx,
}
for urlIdx in urlIdxs
@@ -239,7 +233,8 @@ async def upload_pipeline(
with open(file_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
headers = {'Authorization': f'Bearer {key}'}
@@ -299,7 +294,8 @@ async def add_pipeline(request: Request, form_data: AddPipelineForm, user=Depend
try:
urlIdx = form_data.urlIdx
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
@@ -342,7 +338,8 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user=
try:
urlIdx = form_data.urlIdx
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.delete(
@@ -378,7 +375,8 @@ async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user=
async def get_pipelines(request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)):
response = None
try:
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
@@ -418,7 +416,8 @@ async def get_pipeline_valves(
):
response = None
try:
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
@@ -458,7 +457,8 @@ async def get_pipeline_valves_spec(
):
response = None
try:
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
@@ -499,7 +499,8 @@ async def update_pipeline_valves(
):
response = None
try:
url, key = await get_openai_connection(urlIdx)
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
+5 -6
View File
@@ -7,7 +7,6 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
@@ -150,13 +149,13 @@ async def create_new_prompt(
await has_permission(
user.id,
'workspace.prompts',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
)
or await has_permission(
user.id,
'workspace.prompts_import',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
@@ -166,7 +165,7 @@ async def create_new_prompt(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -282,7 +281,7 @@ async def update_prompt_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -439,7 +438,7 @@ async def update_prompt_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -259,6 +259,10 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False)
log.info(f'SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}')
# Handle both ConfigVar and direct value
if hasattr(enable_scim, 'value'):
enable_scim = enable_scim.value
if not enable_scim:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -267,6 +271,9 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
# Verify the SCIM token
scim_token = getattr(request.app.state, 'SCIM_TOKEN', None)
# Handle both ConfigVar and direct value
if hasattr(scim_token, 'value'):
scim_token = scim_token.value
log.debug(f'SCIM token configured: {bool(scim_token)}')
if not scim_token or not hmac.compare_digest(token, scim_token):
raise HTTPException(
@@ -665,10 +672,7 @@ async def update_user(
if user_data.emails and len(user_data.emails) > 0:
update_data['email'] = user_data.emails[0].value
# Do not let SCIM's active flag demote an existing admin: a routine IdP sync or misconfiguration
# must not silently strip a locally-provisioned admin's role and lock the instance out. Admin
# role changes go through the dedicated admin endpoints, not SCIM provisioning.
if user_data.active is not None and user.role != 'admin':
if user_data.active is not None:
update_data['role'] = 'user' if user_data.active else 'pending'
if user_data.photos and len(user_data.photos) > 0:
@@ -715,9 +719,7 @@ async def patch_user(
if op == 'replace':
if path == 'active':
# Same guard as update_user: never demote an existing admin via SCIM.
if user.role != 'admin':
update_data['role'] = 'user' if value else 'pending'
update_data['role'] = 'user' if value else 'pending'
elif path == 'userName':
update_data['email'] = value
elif path == 'displayName':
+5 -6
View File
@@ -6,7 +6,6 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.skills import (
SkillAccessListResponse,
@@ -131,7 +130,7 @@ async def export_skills(
if user.role != 'admin' and not await has_permission(
user.id,
'workspace.skills',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
@@ -158,7 +157,7 @@ async def create_new_skill(
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'workspace.skills', await Config.get('user.permissions'), db=db
user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -180,7 +179,7 @@ async def create_new_skill(
# grants in the create payload, bypassing the sharing.public_skills gate
# that the dedicated /access/update endpoint already enforces.
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -293,7 +292,7 @@ async def update_skill_by_id(
# they may set, so a non-admin owner cannot make their own skill publicly
# readable/writable without sharing.public_skills permission.
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -362,7 +361,7 @@ async def update_skill_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
+99 -77
View File
@@ -16,7 +16,6 @@ from open_webui.config import (
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
)
from open_webui.constants import ERROR_MESSAGES, TASKS
from open_webui.models.config import Config
from open_webui.routers.pipelines import process_pipeline_inlet_filter
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat import generate_chat_completion
@@ -37,36 +36,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
TASK_CONFIG_KEYS = {
'TASK_MODEL': 'task.model.default',
'TASK_MODEL_EXTERNAL': 'task.model.external',
'TITLE_GENERATION_PROMPT_TEMPLATE': 'task.title.prompt_template',
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': 'task.image.prompt_template',
'ENABLE_AUTOCOMPLETE_GENERATION': 'task.autocomplete.enable',
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': 'task.autocomplete.input_max_length',
'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE': 'task.autocomplete.prompt_template',
'TAGS_GENERATION_PROMPT_TEMPLATE': 'task.tags.prompt_template',
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': 'task.follow_up.prompt_template',
'ENABLE_FOLLOW_UP_GENERATION': 'task.follow_up.enable',
'ENABLE_TAGS_GENERATION': 'task.tags.enable',
'ENABLE_TITLE_GENERATION': 'task.title.enable',
'ENABLE_SEARCH_QUERY_GENERATION': 'task.query.search.enable',
'ENABLE_RETRIEVAL_QUERY_GENERATION': 'task.query.retrieval.enable',
'QUERY_GENERATION_PROMPT_TEMPLATE': 'task.query.prompt_template',
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': 'task.tools.prompt_template',
'ENABLE_VOICE_MODE_PROMPT': 'task.voice.prompt.enable',
'VOICE_MODE_PROMPT_TEMPLATE': 'task.voice.prompt_template',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
values = await Config.get_many(*key_map.values())
return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
def config_updates(data: dict, key_map: dict[str, str]) -> dict:
return {key_map[field]: value for field, value in data.items() if field in key_map}
##################################
#
@@ -90,7 +59,25 @@ async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=
@router.get('/config')
async def get_task_config(request: Request, user=Depends(get_verified_user)):
return await get_config_values(TASK_CONFIG_KEYS)
return {
'TASK_MODEL': request.app.state.config.TASK_MODEL,
'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL,
'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION,
'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION,
'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
class TaskConfigForm(BaseModel):
@@ -101,7 +88,6 @@ class TaskConfigForm(BaseModel):
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
ENABLE_AUTOCOMPLETE_GENERATION: bool
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE: str
TAGS_GENERATION_PROMPT_TEMPLATE: str
FOLLOW_UP_GENERATION_PROMPT_TEMPLATE: str
ENABLE_FOLLOW_UP_GENERATION: bool
@@ -116,13 +102,56 @@ class TaskConfigForm(BaseModel):
@router.post('/config/update')
async def update_task_config(request: Request, form_data: TaskConfigForm, user=Depends(get_admin_user)):
await Config.upsert(config_updates(form_data.model_dump(), TASK_CONFIG_KEYS))
return await get_config_values(TASK_CONFIG_KEYS)
request.app.state.config.TASK_MODEL = form_data.TASK_MODEL
request.app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL
request.app.state.config.ENABLE_TITLE_GENERATION = form_data.ENABLE_TITLE_GENERATION
request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = form_data.TITLE_GENERATION_PROMPT_TEMPLATE
request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = form_data.ENABLE_FOLLOW_UP_GENERATION
request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = form_data.ENABLE_AUTOCOMPLETE_GENERATION
request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = (
form_data.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH
)
request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = form_data.TAGS_GENERATION_PROMPT_TEMPLATE
request.app.state.config.ENABLE_TAGS_GENERATION = form_data.ENABLE_TAGS_GENERATION
request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION = form_data.ENABLE_SEARCH_QUERY_GENERATION
request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = form_data.ENABLE_RETRIEVAL_QUERY_GENERATION
request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE
request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE
return {
'TASK_MODEL': request.app.state.config.TASK_MODEL,
'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL,
'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION,
'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION,
'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION,
'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE,
'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@router.post('/title/completions')
async def generate_title(request: Request, form_data: dict, user=Depends(get_verified_user)):
if not await Config.get('task.title.enable'):
if not request.app.state.config.ENABLE_TITLE_GENERATION:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'detail': 'Title generation is disabled'},
@@ -152,16 +181,15 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating chat title using model {task_model_id} for user {user.email} ')
title_template = await Config.get('task.title.prompt_template')
if title_template != '':
template = title_template
if request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != '':
template = request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
@@ -206,7 +234,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
@router.post('/follow_up/completions')
async def generate_follow_ups(request: Request, form_data: dict, user=Depends(get_verified_user)):
if not await Config.get('task.follow_up.enable'):
if not request.app.state.config.ENABLE_FOLLOW_UP_GENERATION:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'detail': 'Follow-up generation is disabled'},
@@ -231,16 +259,15 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating chat title using model {task_model_id} for user {user.email} ')
follow_up_template = await Config.get('task.follow_up.prompt_template')
if follow_up_template != '':
template = follow_up_template
if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != '':
template = request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
@@ -276,7 +303,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
@router.post('/tags/completions')
async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get_verified_user)):
if not await Config.get('task.tags.enable'):
if not request.app.state.config.ENABLE_TAGS_GENERATION:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'detail': 'Tags generation is disabled'},
@@ -301,16 +328,15 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating chat tags using model {task_model_id} for user {user.email} ')
tags_template = await Config.get('task.tags.prompt_template')
if tags_template != '':
template = tags_template
if request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != '':
template = request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
@@ -365,16 +391,15 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating image prompt using model {task_model_id} for user {user.email} ')
image_prompt_template = await Config.get('task.image.prompt_template')
if image_prompt_template != '':
template = image_prompt_template
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != '':
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
@@ -412,13 +437,13 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
async def generate_queries(request: Request, form_data: dict, user=Depends(get_verified_user)):
type = form_data.get('type')
if type == 'web_search':
if not await Config.get('task.query.search.enable'):
if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'),
)
elif type == 'retrieval':
if not await Config.get('task.query.retrieval.enable'):
if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'),
@@ -447,16 +472,15 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating {type} queries using model {task_model_id} for user {user.email}')
query_template = await Config.get('task.query.prompt_template')
if query_template.strip() != '':
template = query_template
if (request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != '':
template = request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
@@ -491,7 +515,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
@router.post('/auto/completions')
async def generate_autocompletion(request: Request, form_data: dict, user=Depends(get_verified_user)):
if not await Config.get('task.autocomplete.enable'):
if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'),
@@ -501,12 +525,11 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
prompt = form_data.get('prompt')
messages = form_data.get('messages')
autocomplete_input_max_length = await Config.get('task.autocomplete.input_max_length')
if autocomplete_input_max_length > 0:
if len(prompt) > autocomplete_input_max_length:
if request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH > 0:
if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.INPUT_TOO_LONG(autocomplete_input_max_length),
detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH),
)
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
@@ -528,16 +551,15 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(f'generating autocompletion using model {task_model_id} for user {user.email}')
autocomplete_template = await Config.get('task.autocomplete.prompt_template')
if autocomplete_template.strip() != '':
template = autocomplete_template
if (request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != '':
template = request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
@@ -592,8 +614,8 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
+5 -13
View File
@@ -14,7 +14,6 @@ from fastapi import APIRouter, Depends, Request, Response, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from open_webui.config import TERMINAL_PROXY_HEADERS
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_connection_access
@@ -44,9 +43,6 @@ def _sanitize_proxy_path(path: str) -> str | None:
if once == decoded:
break
decoded = once
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
if unquote(decoded) != decoded:
return None
had_trailing_slash = decoded.endswith('/')
normalized = posixpath.normpath(decoded)
# Remove any leading slashes that would reset the base
@@ -63,7 +59,7 @@ def _sanitize_proxy_path(path: str) -> str | None:
@router.get('/')
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
"""Return terminal servers the authenticated user has access to."""
connections = await Config.get('terminal_server.connections', []) or []
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
return [
@@ -88,7 +84,7 @@ async def proxy_terminal(
user=Depends(get_verified_user),
):
"""Proxy a request to the admin terminal server identified by *server_id*."""
connections = await Config.get('terminal_server.connections', []) or []
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if connection is None:
@@ -236,7 +232,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
return None
# Resolve terminal server
connections = await Config.get('terminal_server.connections', []) or []
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if connection is None:
@@ -286,14 +282,10 @@ async def ws_terminal(
import urllib.parse
# Encode session_id as an opaque path segment so it cannot smuggle '?'/'#'/'&' (at any
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
safe_session_id = urllib.parse.quote(session_id, safe='')
if policy_id:
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{session_id}'
else:
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
upstream_url = f'{ws_base}/api/terminals/{session_id}'
if upstream_params:
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
+10 -11
View File
@@ -13,7 +13,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.tools import (
@@ -85,7 +84,7 @@ async def get_tools(
server_access_grants = {}
for server in await get_tool_servers(request):
server_idx = server.get('idx', 0)
connections = await Config.get('tool_server.connections', [])
connections = request.app.state.config.TOOL_SERVER_CONNECTIONS
if server_idx >= len(connections):
log.warning(
f'Tool server index {server_idx} out of range '
@@ -114,7 +113,7 @@ async def get_tools(
)
# MCP Tool Servers
for server in await Config.get('tool_server.connections', []):
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server.get('type', 'openapi') == 'mcp' and server.get('config', {}).get('enable'):
server_id = server.get('info', {}).get('id')
auth_type = server.get('auth_type', 'none')
@@ -304,7 +303,7 @@ async def export_tools(
if user.role != 'admin' and not await has_permission(
user.id,
'workspace.tools_export',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
@@ -332,11 +331,11 @@ async def create_new_tools(
):
"""Create a new tool from user-supplied Python source code."""
if user.role != 'admin' and not (
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db)
or await has_permission(
user.id,
'workspace.tools_import',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
@@ -357,7 +356,7 @@ async def create_new_tools(
if tools is None:
try:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -485,8 +484,8 @@ async def update_tools_by_id(
# Content edits trigger exec on load — gate them behind workspace.tools (matches /create).
if form_data.content != tools.content:
if user.role != 'admin' and not (
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
or await has_permission(user.id, 'workspace.tools_import', await Config.get('user.permissions'), db=db)
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,
@@ -504,7 +503,7 @@ async def update_tools_by_id(
specs = get_tool_specs(TOOLS[id])
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
@@ -575,7 +574,7 @@ async def update_tool_access_by_id(
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
+13 -34
View File
@@ -12,7 +12,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import (
@@ -39,7 +38,7 @@ from open_webui.utils.auth import (
get_verified_user,
validate_password,
)
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -158,7 +157,7 @@ async def get_user_permissisions(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
return user_permissions
@@ -202,8 +201,6 @@ class AccessGrantsPermissions(BaseModel):
class ChatPermissions(BaseModel):
model_config = ConfigDict(populate_by_name=True)
controls: bool = True
valves: bool = True
system_prompt: bool = True
@@ -218,7 +215,6 @@ class ChatPermissions(BaseModel):
edit: bool = True
share: bool = True
export: bool = True
import_: bool = Field(default=True, alias='import')
stt: bool = True
tts: bool = True
call: bool = True
@@ -240,7 +236,6 @@ class FeaturesPermissions(BaseModel):
memories: bool = True
automations: bool = False
calendar: bool = True
webhooks: bool = False
class SettingsPermissions(BaseModel):
@@ -258,36 +253,20 @@ class UserPermissions(BaseModel):
@router.get('/default/permissions', response_model=UserPermissions)
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
user_permissions = await Config.get('user.permissions')
return {
'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})),
'sharing': SharingPermissions(**user_permissions.get('sharing', {})),
'access_grants': AccessGrantsPermissions(**user_permissions.get('access_grants', {})),
'chat': ChatPermissions(**user_permissions.get('chat', {})),
'features': FeaturesPermissions(**user_permissions.get('features', {})),
'settings': SettingsPermissions(**user_permissions.get('settings', {})),
'workspace': WorkspacePermissions(**request.app.state.config.USER_PERMISSIONS.get('workspace', {})),
'sharing': SharingPermissions(**request.app.state.config.USER_PERMISSIONS.get('sharing', {})),
'access_grants': AccessGrantsPermissions(**request.app.state.config.USER_PERMISSIONS.get('access_grants', {})),
'chat': ChatPermissions(**request.app.state.config.USER_PERMISSIONS.get('chat', {})),
'features': FeaturesPermissions(**request.app.state.config.USER_PERMISSIONS.get('features', {})),
'settings': SettingsPermissions(**request.app.state.config.USER_PERMISSIONS.get('settings', {})),
}
@router.post('/default/permissions')
async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)):
user_permissions = form_data.model_dump(by_alias=True)
await Config.upsert({'user.permissions': user_permissions})
return user_permissions
@router.get('/default/permissions/defaults', response_model=UserPermissions)
async def get_default_user_permissions_defaults(user=Depends(get_admin_user)):
from open_webui.config import DEFAULT_USER_PERMISSIONS
return {
'workspace': WorkspacePermissions(**DEFAULT_USER_PERMISSIONS.get('workspace', {})),
'sharing': SharingPermissions(**DEFAULT_USER_PERMISSIONS.get('sharing', {})),
'access_grants': AccessGrantsPermissions(**DEFAULT_USER_PERMISSIONS.get('access_grants', {})),
'chat': ChatPermissions(**DEFAULT_USER_PERMISSIONS.get('chat', {})),
'features': FeaturesPermissions(**DEFAULT_USER_PERMISSIONS.get('features', {})),
'settings': SettingsPermissions(**DEFAULT_USER_PERMISSIONS.get('settings', {})),
}
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
return request.app.state.config.USER_PERMISSIONS
############################
@@ -324,7 +303,7 @@ async def update_user_settings_by_session_user(
and not await has_permission(
user.id,
'features.direct_tool_servers',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
)
):
# If the user is not an admin and does not have permission to use tool servers, remove the key
@@ -351,7 +330,7 @@ async def get_user_status_by_session_user(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not await Config.get('users.enable_status'):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
@@ -372,7 +351,7 @@ async def update_user_status_by_session_user(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not await Config.get('users.enable_status'):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+8 -9
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.chats import ChatTitleMessagesForm
from open_webui.models.config import Config
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.misc import get_gravatar_url
@@ -42,27 +41,27 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)):
@router.post('/code/execute')
async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)):
if not await Config.get('code_execution.enable'):
if not request.app.state.config.ENABLE_CODE_EXECUTION:
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'),
)
if await Config.get('code_execution.engine') == 'jupyter':
if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter':
output = await execute_code_jupyter(
await Config.get('code_execution.jupyter.url'),
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
form_data.code,
(
await Config.get('code_execution.jupyter.auth_token')
if await Config.get('code_execution.jupyter.auth') == 'token'
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'token'
else None
),
(
await Config.get('code_execution.jupyter.auth_password')
if await Config.get('code_execution.jupyter.auth') == 'password'
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'password'
else None
),
await Config.get('code_execution.jupyter.timeout'),
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
)
return output
+9 -16
View File
@@ -757,13 +757,11 @@ async def yjs_document_update(sid, data):
@sio.on('ydoc:document:leave')
async def yjs_document_leave(sid, data):
"""Handle user leaving a document"""
user = SESSION_POOL.get(sid)
if not user: # authenticated session required (parity with sibling handlers)
return
try:
document_id = normalize_document_id(data['document_id'])
user_id = data.get('user_id', sid)
log.info(f'User {user["id"]} leaving document {document_id}')
log.info(f'User {user_id} leaving document {document_id}')
# Remove user from the document
await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
@@ -771,10 +769,10 @@ async def yjs_document_leave(sid, data):
# Leave Socket.IO room
await sio.leave_room(sid, f'doc_{document_id}')
# Notify other users; user_id is the authenticated identity, not client-supplied
# Notify other users
await sio.emit(
'ydoc:user:left',
{'document_id': document_id, 'user_id': user['id']},
{'document_id': document_id, 'user_id': user_id},
room=f'doc_{document_id}',
)
@@ -789,21 +787,16 @@ async def yjs_document_leave(sid, data):
@sio.on('ydoc:awareness:update')
async def yjs_awareness_update(sid, data):
"""Handle awareness updates (cursors, selections, etc.)"""
user = SESSION_POOL.get(sid)
if not user: # authenticated session required (parity with sibling handlers)
return
try:
document_id = normalize_document_id(data['document_id'])
room = f'doc_{document_id}'
if room not in sio.rooms(sid): # must have joined the document first
return
document_id = data['document_id']
user_id = data.get('user_id', sid)
update = data['update']
# Broadcast to the room; user_id is the authenticated identity, not client-supplied
# Broadcast awareness update to all other users in the document
await sio.emit(
'ydoc:awareness:update',
{'document_id': document_id, 'user_id': user['id'], 'update': update},
room=room,
{'document_id': document_id, 'user_id': user_id, 'update': update},
room=f'doc_{document_id}',
skip_sid=sid,
)
+10 -12
View File
@@ -18,7 +18,6 @@ from fastapi import Request
from open_webui.models.channels import Channel, ChannelMember, Channels
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.memories import Memories
from open_webui.models.messages import Message, Messages
@@ -226,10 +225,10 @@ async def search_web(
return json.dumps({'error': 'Request context not available'})
try:
engine = await Config.get('rag.web.search.engine')
engine = __request__.app.state.config.WEB_SEARCH_ENGINE
user = UserModel(**__user__) if __user__ else None
configured = await Config.get('rag.web.search.result_count')
configured = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT
max_count = 5 if configured is None else configured
count = max(1, min(count, max_count)) if count is not None else max_count
@@ -267,7 +266,7 @@ async def fetch_url(
# Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH)
# Guard: content may be None if the web loader silently failed
if content is not None:
max_length = await Config.get('rag.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...]'
else:
@@ -476,7 +475,7 @@ async def execute_code(
)
code = blocking_code + '\n' + code
engine = await Config.get('code_interpreter.engine', 'pyodide')
engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
if engine == 'pyodide':
# Execute via frontend pyodide using bidirectional event call
if __event_call__ is None:
@@ -514,22 +513,21 @@ async def execute_code(
elif engine == 'jupyter':
from open_webui.utils.code_interpreter import execute_code_jupyter
jupyter_auth = await Config.get('code_interpreter.jupyter.auth')
output = await execute_code_jupyter(
await Config.get('code_interpreter.jupyter.url'),
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
code,
(
await Config.get('code_interpreter.jupyter.auth_token')
if jupyter_auth == 'token'
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token'
else None
),
(
await Config.get('code_interpreter.jupyter.auth_password')
if jupyter_auth == 'password'
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password'
else None
),
await Config.get('code_interpreter.jupyter.timeout'),
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
)
stdout = output.get('stdout', '')
@@ -114,7 +114,7 @@ async def has_access(
Check if a user has the specified permission using an in-memory access_grants list.
Used for config-driven resources (arena models, tool servers) that store
access control as JSON config rather than in the access_grant DB table.
access control as JSON in ConfigVar rather than in the access_grant DB table.
Semantics:
- None or [] private (owner-only, deny all)
@@ -321,8 +321,7 @@ async def check_model_access(
return
if model_info:
# Enforce for every non-admin role (including pending); never fail open.
if user.role != 'admin':
if user.role == 'user':
from open_webui.models.access_grants import AccessGrants
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
@@ -38,33 +38,25 @@ async def has_access_to_file(
if file.user_id == user.id:
return True
# Check if the file is associated with any knowledge bases the user has access to.
# An object (knowledge base or workspace model) confers write/delete on a file only when
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
for knowledge_base in knowledge_bases:
if (
knowledge_base.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
)
) and (access_type == 'read' or knowledge_base.user_id == file.user_id):
if knowledge_base.user_id == user.id or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
if knowledge_base_id:
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id and (
access_type == 'read' or knowledge_base.user_id == file.user_id
):
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
@@ -86,14 +78,12 @@ async def has_access_to_file(
if accessible_ids:
return True
# Check if the file is directly attached to a shared workspace model (per the ownership
# note above, model write is conferred only for files the model owner owns).
# Check if the file is directly attached to a shared workspace model
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
knowledge_items = getattr(model.meta, 'knowledge', None) or []
for item in knowledge_items:
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
if access_type == 'read' or model.user_id == file.user_id:
return True
return True
return False
@@ -1,23 +0,0 @@
from open_webui.models.access_grants import AccessGrants
from open_webui.models.folders import FolderModel, Folders
from sqlalchemy.ext.asyncio import AsyncSession
async def has_folder_access(
user_id: str, folder: FolderModel, permission: str, db: AsyncSession
) -> bool:
"""Check if user has access to folder directly or via ancestor inheritance."""
if await AccessGrants.has_access(
user_id=user_id,
resource_type='folder',
resource_id=folder.id,
permission=permission,
db=db,
):
return True
# Check ancestor chain for inherited access
if folder.parent_id:
parent = await Folders.get_folder_by_id(folder.parent_id, db=db)
if parent:
return await has_folder_access(user_id, parent, permission, db)
return False
+109 -253
View File
@@ -89,26 +89,6 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di
##############################
def _copy_cache_control(source: dict, target: dict) -> dict:
if isinstance(source, dict) and 'cache_control' in source:
target['cache_control'] = source['cache_control']
return target
def _has_cache_control(blocks: list) -> bool:
return any(isinstance(block, dict) and 'cache_control' in block for block in blocks)
def _finalize_openai_content(blocks: list) -> str | list:
if not blocks:
return ''
if len(blocks) == 1 and blocks[0].get('type') == 'text' and not _has_cache_control(blocks):
return blocks[0].get('text', '')
return blocks
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
@@ -132,21 +112,14 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if isinstance(system, str):
messages.append({'role': 'system', 'content': system})
elif isinstance(system, list):
openai_content = []
# Anthropic supports system as list of content blocks
text_parts = []
for block in system:
if isinstance(block, dict) and block.get('type') == 'text':
openai_content.append(
_copy_cache_control(
block,
{
'type': 'text',
'text': block.get('text', ''),
},
)
)
text_parts.append(block.get('text', ''))
elif isinstance(block, str):
openai_content.append({'type': 'text', 'text': block})
messages.append({'role': 'system', 'content': _finalize_openai_content(openai_content)})
text_parts.append(block)
messages.append({'role': 'system', 'content': '\n'.join(text_parts)})
# Convert messages
for msg in anthropic_payload.get('messages', []):
@@ -165,13 +138,10 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if block_type == 'text':
openai_content.append(
_copy_cache_control(
block,
{
'type': 'text',
'text': block.get('text', ''),
},
)
{
'type': 'text',
'text': block.get('text', ''),
}
)
elif block_type == 'image':
source = block.get('source', {})
@@ -179,25 +149,19 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
media_type = source.get('media_type', 'image/png')
data = source.get('data', '')
openai_content.append(
_copy_cache_control(
block,
{
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
{
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
)
}
)
elif source.get('type') == 'url':
openai_content.append(
_copy_cache_control(
block,
{
'type': 'image_url',
'image_url': {'url': source.get('url', '')},
},
)
{
'type': 'image_url',
'image_url': {'url': source.get('url', '')},
}
)
elif block_type == 'tool_use':
tool_calls.append(
@@ -232,13 +196,10 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if content_type == 'text':
converted_parts.append(
_copy_cache_control(
content_block,
{
'type': 'text',
'text': content_block.get('text', ''),
},
)
{
'type': 'text',
'text': content_block.get('text', ''),
}
)
elif content_type == 'image':
source = content_block.get('source', {})
@@ -246,27 +207,21 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
media_type = source.get('media_type', 'image/png')
data = source.get('data', '')
converted_parts.append(
_copy_cache_control(
content_block,
{
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
{
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
)
}
)
elif source.get('type') == 'url':
converted_parts.append(
_copy_cache_control(
content_block,
{
'type': 'image_url',
'image_url': {
'url': source.get('url', ''),
},
{
'type': 'image_url',
'image_url': {
'url': source.get('url', ''),
},
)
}
)
elif content_type == 'document':
# Documents have no direct OpenAI equivalent;
@@ -299,9 +254,7 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
converted_parts.append({'type': 'text', 'text': search_text})
# Flatten to string when only text parts are present
if all(part.get('type') == 'text' for part in converted_parts) and not _has_cache_control(
converted_parts
):
if all(part.get('type') == 'text' for part in converted_parts):
tool_content = '\n'.join(part.get('text', '') for part in converted_parts)
elif converted_parts:
tool_content = converted_parts
@@ -334,13 +287,21 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
# Assistant message with tool calls
msg_dict = {'role': role}
if openai_content:
msg_dict['content'] = _finalize_openai_content(openai_content)
# If there's only text, flatten it
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
msg_dict['content'] = openai_content[0]['text']
else:
msg_dict['content'] = openai_content
else:
msg_dict['content'] = ''
msg_dict['tool_calls'] = tool_calls
messages.append(msg_dict)
elif openai_content:
messages.append({'role': role, 'content': _finalize_openai_content(openai_content)})
# If there's only a single text block, flatten it to a string
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
messages.append({'role': role, 'content': openai_content[0]['text']})
else:
messages.append({'role': role, 'content': openai_content})
else:
messages.append({'role': role, 'content': str(content) if content else ''})
@@ -351,7 +312,7 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
# Common parameters
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
for param in ('temperature', 'top_p', 'stop_sequences', 'stream'):
if param in anthropic_payload:
if param == 'stop_sequences':
openai_payload['stop'] = anthropic_payload[param]
@@ -363,33 +324,30 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
openai_tools = []
for tool in anthropic_payload['tools']:
openai_tools.append(
_copy_cache_control(
tool,
{
'type': 'function',
'function': {
'name': tool.get('name', ''),
'description': tool.get('description', ''),
'parameters': tool.get('input_schema', {}),
},
{
'type': 'function',
'function': {
'name': tool.get('name', ''),
'description': tool.get('description', ''),
'parameters': tool.get('input_schema', {}),
},
)
}
)
openai_payload['tools'] = openai_tools
# tool_choice
if 'tool_choice' in anthropic_payload:
tool_choice = anthropic_payload['tool_choice']
if isinstance(tool_choice, dict):
tool_choice_type = tool_choice.get('type', 'auto')
if tool_choice_type == 'auto':
tc = anthropic_payload['tool_choice']
if isinstance(tc, dict):
tc_type = tc.get('type', 'auto')
if tc_type == 'auto':
openai_payload['tool_choice'] = 'auto'
elif tool_choice_type == 'any':
elif tc_type == 'any':
openai_payload['tool_choice'] = 'required'
elif tool_choice_type == 'tool':
elif tc_type == 'tool':
openai_payload['tool_choice'] = {
'type': 'function',
'function': {'name': tool_choice.get('name', '')},
'function': {'name': tc.get('name', '')},
}
return openai_payload
@@ -419,23 +377,23 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
# Build content blocks
content = []
message_content = message.get('content')
if message_content:
content.append({'type': 'text', 'text': message_content})
msg_content = message.get('content')
if msg_content:
content.append({'type': 'text', 'text': msg_content})
# Tool calls -> tool_use blocks
tool_calls = message.get('tool_calls') or []
for tool_call in tool_calls:
function = tool_call.get('function', {})
# Tool calls tool_use blocks
tool_calls = message.get('tool_calls', [])
for tc in tool_calls:
func = tc.get('function', {})
try:
tool_input = json.loads(function.get('arguments', '{}'))
tool_input = json.loads(func.get('arguments', '{}'))
except (json.JSONDecodeError, TypeError):
tool_input = {}
content.append(
{
'type': 'tool_use',
'id': tool_call.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'),
'name': function.get('name', ''),
'id': tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'),
'name': func.get('name', ''),
'input': tool_input,
}
)
@@ -446,10 +404,6 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
'input_tokens': openai_usage.get('prompt_tokens', 0),
'output_tokens': openai_usage.get('completion_tokens', 0),
}
if 'cache_creation_input_tokens' in openai_usage:
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
if 'cache_read_input_tokens' in openai_usage:
usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens']
return {
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
@@ -472,14 +426,10 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
Handles text content, tool calls, and mixed content with proper
multi-block indexing as required by Anthropic's streaming protocol.
Tool calls are tracked by their unique id (not OpenAI index) so that
parallel calls sharing the same index get distinct Anthropic tool_use
blocks. Each block follows the Anthropic lifecycle: start -> delta -> stop.
"""
import uuid as _uuid
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
msg_id = f'msg_{_uuid.uuid4().hex[:24]}'
input_tokens = 0
output_tokens = 0
stop_reason = 'end_turn'
@@ -489,21 +439,16 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
current_block_index = 0
text_block_open = False
# Accumulated state for each tool call, keyed by tool call id.
# Parallel calls that share the same OpenAI index get distinct entries.
# Each entry: {id, name, arguments, block_index, started, stopped}
tracked_tool_calls = {}
# Map OpenAI tool call index -> tool call id for routing
# argument-only deltas (deltas that carry arguments but no id).
index_to_tool_id = {}
# Whether any tool call block has been emitted (suppresses further text)
has_tool_calls = False
# Track tool call state: maps OpenAI tool_call index -> Anthropic block index
# This allows handling multiple concurrent tool calls.
tool_call_blocks = {} # {openai_tc_index: anthropic_block_index}
tool_call_started = {} # {openai_tc_index: bool}
# Emit message_start
message_start = {
'type': 'message_start',
'message': {
'id': message_id,
'id': msg_id,
'type': 'message',
'role': 'assistant',
'content': [],
@@ -526,14 +471,14 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if not line or not line.startswith('data:'):
continue
data_string = line[5:].strip()
if data_string == '[DONE]':
data_str = line[5:].strip()
if data_str == '[DONE]':
continue
if data_string == '{}':
if data_str == '{}':
continue
try:
data = json.loads(data_string)
data = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
continue
@@ -547,7 +492,6 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
delta = choices[0].get('delta', {})
finish_reason = choices[0].get('finish_reason')
message = choices[0].get('message') or {}
# Update usage if present
if data.get('usage'):
@@ -555,11 +499,10 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
output_tokens = data['usage'].get('completion_tokens', output_tokens)
# --- Handle text content ---
# Anthropic expects text blocks before tool blocks, so skip
# text deltas once any tool call has started.
content = delta.get('content')
if content and not has_tool_calls:
if content is not None:
if not text_block_open:
# Start a new text content block
block_start = {
'type': 'content_block_start',
'index': current_block_index,
@@ -568,6 +511,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
text_block_open = True
# Send text delta
block_delta = {
'type': 'content_block_delta',
'index': current_block_index,
@@ -576,12 +520,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle tool calls ---
# Some providers put tool_calls on the final message object
# instead of the delta; fall back to that when needed.
tool_calls = delta.get('tool_calls') or []
if not tool_calls and message.get('tool_calls'):
tool_calls = message['tool_calls']
tool_calls = delta.get('tool_calls')
if tool_calls:
# Close text block if one is open (text comes before tools)
if text_block_open:
@@ -593,95 +532,43 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
text_block_open = False
current_block_index += 1
for tool_call in tool_calls:
tool_call_index = tool_call.get('index', 0)
tool_call_id = tool_call.get('id', '')
tool_call_name = (tool_call.get('function') or {}).get('name', '')
arguments_chunk = (tool_call.get('function') or {}).get('arguments', '')
for tc in tool_calls:
tc_index = tc.get('index', 0)
# Resolve which tracked tool call this delta belongs to.
# A delta with an id starts or identifies a specific tool.
# A delta without an id carries arguments for the most
# recent tool at this OpenAI index.
if tool_call_id:
if tool_call_id not in tracked_tool_calls:
tracked_tool_calls[tool_call_id] = {
'id': tool_call_id,
'name': tool_call_name,
'arguments': '',
'block_index': -1,
'started': False,
'stopped': False,
}
index_to_tool_id[tool_call_index] = tool_call_id
tool = tracked_tool_calls[tool_call_id]
elif tool_call_index in index_to_tool_id:
tool = tracked_tool_calls[index_to_tool_id[tool_call_index]]
else:
# First delta for this index with no id; create a
# provisional entry with a generated fallback id.
fallback_id = f'toolu_{_uuid.uuid4().hex[:24]}'
tracked_tool_calls[fallback_id] = {
'id': fallback_id,
'name': tool_call_name,
'arguments': '',
'block_index': -1,
'started': False,
'stopped': False,
}
index_to_tool_id[tool_call_index] = fallback_id
tool = tracked_tool_calls[fallback_id]
if tc_index not in tool_call_started:
# First time seeing this tool call — emit content_block_start
tool_call_blocks[tc_index] = current_block_index
tool_call_started[tc_index] = True
# Update name if provided on a later delta
if tool_call_name and not tool['name']:
tool['name'] = tool_call_name
# Emit content_block_start once we have a name
if not tool['started'] and tool['name']:
tool['block_index'] = current_block_index
tool['started'] = True
has_tool_calls = True
# Extract tool call ID and name from the first chunk
tc_id = tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}')
tc_name = tc.get('function', {}).get('name', '')
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {
'type': 'tool_use',
'id': tool['id'],
'name': tool['name'],
'id': tc_id,
'name': tc_name,
'input': {},
},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
current_block_index += 1
# Buffer arguments and emit as input_json_delta
if arguments_chunk:
tool['arguments'] += arguments_chunk
if tool['started'] and not tool['stopped']:
block_delta = {
'type': 'content_block_delta',
'index': tool['block_index'],
'delta': {
'type': 'input_json_delta',
'partial_json': arguments_chunk,
},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# Close the block once arguments form complete JSON
if tool['started'] and not tool['stopped']:
try:
json.loads(tool['arguments'])
tool['stopped'] = True
block_stop = {
'type': 'content_block_stop',
'index': tool['block_index'],
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
except (json.JSONDecodeError, ValueError):
pass
# Emit argument chunks as input_json_delta
args_chunk = tc.get('function', {}).get('arguments', '')
if args_chunk:
block_delta = {
'type': 'content_block_delta',
'index': tool_call_blocks[tc_index],
'delta': {
'type': 'input_json_delta',
'partial_json': args_chunk,
},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle finish reason ---
if finish_reason is not None:
@@ -695,46 +582,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except Exception as e:
log.error(f'Error in Anthropic stream conversion: {e}')
# Flush any tools that buffered arguments but never emitted a block
for tool in tracked_tool_calls.values():
if not tool['started'] and tool['name']:
tool['block_index'] = current_block_index
tool['started'] = True
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {
'type': 'tool_use',
'id': tool['id'],
'name': tool['name'],
'input': {},
},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
current_block_index += 1
if tool['arguments']:
block_delta = {
'type': 'content_block_delta',
'index': tool['block_index'],
'delta': {
'type': 'input_json_delta',
'partial_json': tool['arguments'],
},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# Close any open text block
if text_block_open:
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Close any tool call blocks that are still open
for tool in tracked_tool_calls.values():
if tool['started'] and not tool['stopped']:
block_stop = {'type': 'content_block_stop', 'index': tool['block_index']}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Close any open tool call blocks
for tc_index, block_index in tool_call_blocks.items():
block_stop = {'type': 'content_block_stop', 'index': block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Emit message_delta with stop reason
message_delta = {
+1 -2
View File
@@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.env import CUSTOM_API_KEY_HEADER
from open_webui.internal.db import ScopedSession
from open_webui.models.config import Config
from open_webui.utils.auth import get_http_authorization_cred
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
@@ -166,7 +165,7 @@ class AuthTokenMiddleware:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
request.state.token = token
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS
async def send_with_timing(message: Message) -> None:
if message['type'] == 'http.response.start':
+5 -8
View File
@@ -35,7 +35,6 @@ from open_webui.env import (
pk,
)
from open_webui.models.auths import Auths
from open_webui.models.config import Config
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
from pytz import UTC
@@ -410,16 +409,12 @@ async def get_current_user_by_api_key(request, api_key: str):
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
user_permissions = await Config.get('user.permissions')
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
if not request.state.enable_api_keys or (
user.role != 'admin'
and not await has_permission(
user.id,
'features.api_keys',
user_permissions,
request.app.state.config.USER_PERMISSIONS,
)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
@@ -427,8 +422,10 @@ async def get_current_user_by_api_key(request, api_key: str):
# Enforce endpoint restrictions — checked here (not in middleware)
# so it applies regardless of how the API key was transported
# (Authorization header, cookie, x-api-key header, etc.).
if enable_endpoint_restrictions:
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip() for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip()
]
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
if not is_allowed:
+8 -18
View File
@@ -29,7 +29,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_async_db
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.users import Users
from open_webui.utils.task import prompt_template
from starlette.datastructures import Headers
@@ -173,7 +172,7 @@ async def scheduler_worker_loop(app) -> None:
while True:
try:
# ── Automations ──
if await Config.get('automations.enable'):
if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False):
try:
async with get_async_db() as db:
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
@@ -185,7 +184,7 @@ async def scheduler_worker_loop(app) -> None:
log.exception('Scheduler: automation error')
# ── Calendar Alerts ──
if await Config.get('calendar.enable'):
if getattr(app.state.config, 'ENABLE_CALENDAR', False):
try:
await _check_calendar_alerts(app)
except Exception:
@@ -240,7 +239,7 @@ def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
return list(tool_ids) if tool_ids else []
async def _resolve_model_features(app, model_id: str) -> dict:
def _resolve_model_features(app, model_id: str) -> dict:
"""Read model default features from model config.
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
@@ -257,13 +256,14 @@ async def _resolve_model_features(app, model_id: str) -> dict:
return {}
capabilities = meta.get('capabilities', {})
config = app.state.config
features = {}
# code_interpreter is excluded: it requires the frontend event emitter
# and does not work in headless backend execution.
feature_checks = {
'web_search': await Config.get('rag.web.search.enable'),
'image_generation': await Config.get('image_generation.enable'),
'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False),
'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False),
}
for feature_id in default_feature_ids:
@@ -359,16 +359,6 @@ async def execute_automation(app, automation: AutomationModel) -> None:
await _record_run(automation.id, 'error', error='User not found')
return
# Re-gate the rehydrated owner: a demoted/deactivated or de-permissioned owner must not run.
from open_webui.utils.access_control import has_permission
if user.role not in ('user', 'admin') or (
user.role != 'admin'
and not await has_permission(user.id, 'features.automations', await Config.get('user.permissions'))
):
await _record_run(automation.id, 'error', error='Owner no longer permitted to run automations')
return
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
terminal_config = automation.data.get('terminal')
@@ -436,7 +426,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
# Resolve model defaults (frontend does this, backend doesn't)
tool_ids = _resolve_model_tool_ids(app, model_id)
features = await _resolve_model_features(app, model_id)
features = _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
# Resolve terminal from model config
@@ -561,7 +551,7 @@ async def _check_calendar_alerts(app) -> None:
# Send webhook notification if user has one configured
try:
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False)
if enable_user_webhooks:
user = await Users.get_user_by_id(event.user_id)
-6
View File
@@ -237,12 +237,6 @@ async def generate_chat_completion(
form_data['model'] = selected_model_id
# bypass_filter recursion below skips the line-200 check; gate the resolved model here.
if not bypass_filter and user.role == 'user':
selected_model = request.app.state.MODELS.get(selected_model_id)
if selected_model:
await check_model_access(user, selected_model)
if selected_model_id:
if form_data.get('stream') == True:
+12 -12
View File
@@ -20,7 +20,7 @@ from open_webui.env import (
)
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import validate_url
from open_webui.routers.files import upload_file_handler
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.routers.images import (
@@ -28,6 +28,7 @@ from open_webui.routers.images import (
upload_image,
)
from open_webui.storage.provider import Storage
from open_webui.utils.session_pool import get_session
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
@@ -59,17 +60,16 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
# without re-validation would let an attacker reach private IPs via a
# public host that redirects internally (e.g. cloud-metadata exfil).
validate_url(url)
# Fetch through an SSRF-safe session that re-checks the connect-time IP, so a
# rebinding DNS answer that passed validate_url cannot reach an internal address.
async with get_ssrf_safe_session() as session:
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
image_data = await response.read()
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
# Download the image from the URL
session = await get_session()
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
image_data = await response.read()
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
else:
# Non-URL string — treat as file_id. Delegate to the canonical
# file-ID resolver which enforces ownership/access checks.
-9
View File
@@ -64,18 +64,9 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -
return {}
metadata = metadata or {}
# Extract user_message info for tree mapping
user_message = metadata.get('user_message') or {}
user_message_id = metadata.get('user_message_id', '') or (user_message.get('id', '') if user_message else '')
user_message_parent_id = user_message.get('parentId', '') if user_message else ''
template_vars = {
'{{CHAT_ID}}': metadata.get('chat_id', '') or '',
'{{MESSAGE_ID}}': metadata.get('message_id', '') or '',
'{{USER_MESSAGE_ID}}': user_message_id or '',
'{{USER_MESSAGE_PARENT_ID}}': user_message_parent_id or '',
'{{TASK}}': metadata.get('task', '') or '',
'{{USER_ID}}': (user.id if user else '') or '',
'{{USER_NAME}}': (user.name if user else '') or '',
'{{USER_EMAIL}}': (user.email if user else '') or '',
+55 -60
View File
@@ -40,7 +40,6 @@ from open_webui.env import (
RAG_SYSTEM_CONTEXT,
)
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.functions import Functions
from open_webui.models.models import Models
@@ -981,13 +980,13 @@ async def apply_source_context_to_messages(
if RAG_SYSTEM_CONTEXT:
return add_or_update_system_message(
await rag_template(await Config.get('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(
await rag_template(await Config.get('rag.template'), context, user_message),
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
messages,
append=False,
)
@@ -1291,8 +1290,8 @@ async def chat_completion_tools_handler(
task_model_id = get_task_model_id(
body['model'],
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
@@ -1302,8 +1301,8 @@ async def chat_completion_tools_handler(
specs = [tool['spec'] for tool in tools.values()]
tools_specs = json.dumps(specs, ensure_ascii=False)
if await Config.get('task.tools.prompt_template') != '':
template = await Config.get('task.tools.prompt_template')
if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != '':
template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
else:
template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
@@ -1793,7 +1792,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
system_message_content = ''
if len(input_images) > 0 and await Config.get('images.edit.enable'):
if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT:
# Edit image(s)
try:
images = await image_edits(
@@ -1853,7 +1852,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
else:
# Create image(s)
if await Config.get('image_generation.prompt.enable'):
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
try:
res = await generate_image_prompt(
request,
@@ -2019,17 +2018,17 @@ async def chat_completion_files_handler(
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, prefix=prefix, user=user
),
k=await Config.get('rag.top_k'),
k=request.app.state.config.TOP_K,
reranking_function=(
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user))
if request.app.state.RERANKING_FUNCTION
else None
),
k_reranker=await Config.get('rag.top_k_reranker'),
r=await Config.get('rag.relevance_threshold'),
hybrid_bm25_weight=await Config.get('rag.hybrid_bm25_weight'),
hybrid_search=await Config.get('rag.enable_hybrid_search'),
full_context=all_full_context or await Config.get('rag.full_context'),
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=all_full_context or request.app.state.config.RAG_FULL_CONTEXT,
user=user,
)
except Exception as e:
@@ -2270,7 +2269,7 @@ async def connect_mcp_server(
Returns None if the server is not found or access is denied.
"""
mcp_server_connection = None
for server_connection in await Config.get('tool_server.connections', []):
for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server_connection.get('type', '') == 'mcp' and server_connection.get('info', {}).get('id') == server_id:
mcp_server_connection = server_connection
break
@@ -2443,8 +2442,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
task_model_id = get_task_model_id(
form_data['model'],
await Config.get('task.model.default'),
await Config.get('task.model.external'),
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
@@ -2472,7 +2471,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if 'files' in folder.data:
# Defensive: filter to entries the caller can still read.
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
if metadata.get('params', {}).get('function_calling') == 'legacy':
if metadata.get('params', {}).get('function_calling') != 'native':
form_data['files'] = [
*allowed_files,
*form_data.get('files', []),
@@ -2486,7 +2485,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
user_message = get_last_user_message(form_data['messages'])
model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', False)
if model_knowledge and metadata.get('params', {}).get('function_calling') == 'legacy':
if model_knowledge and metadata.get('params', {}).get('function_calling') != 'native':
await event_emitter(
{
'type': 'status',
@@ -2551,9 +2550,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params['__features__'] = features
if features:
if 'voice' in features and features['voice']:
if await Config.get('task.voice.prompt.enable'):
if await Config.get('task.voice.prompt_template'):
template = await Config.get('task.voice.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
@@ -2564,28 +2563,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if 'memory' in features and features['memory']:
# Skip forced memory injection when native FC is enabled - model can use memory tools
if metadata.get('params', {}).get('function_calling') == 'legacy':
if metadata.get('params', {}).get('function_calling') != 'native':
form_data = await chat_memory_handler(request, form_data, extra_params, user)
if 'web_search' in features and features['web_search']:
# Skip forced RAG web search when native FC is enabled - model can use web_search tool
if metadata.get('params', {}).get('function_calling') == 'legacy':
if metadata.get('params', {}).get('function_calling') != 'native':
form_data = await chat_web_search_handler(request, form_data, extra_params, user)
if 'image_generation' in features and features['image_generation']:
# Skip forced image generation when native FC is enabled - model can use generate_image tool
if metadata.get('params', {}).get('function_calling') == 'legacy':
if metadata.get('params', {}).get('function_calling') != 'native':
form_data = await chat_image_generation_handler(request, form_data, extra_params, user)
if 'code_interpreter' in features and features['code_interpreter']:
engine = await Config.get('code_interpreter.engine', 'pyodide')
engine = getattr(request.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
# Skip XML-tag prompt injection when native FC is enabled —
# execute_code will be injected as a builtin tool instead
if metadata.get('params', {}).get('function_calling') == 'legacy':
if metadata.get('params', {}).get('function_calling') != 'native':
prompt = (
await Config.get('code_interpreter.prompt_template')
if await Config.get('code_interpreter.prompt_template') != ''
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != ''
else DEFAULT_CODE_INTERPRETER_PROMPT
)
@@ -2618,7 +2617,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# If the original caller provided tools, use them as-is (skip resolution).
# Otherwise, save any tools that filter inlets added for merging later.
inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None)
inlet_filter_tools = None if payload_tools else form_data.get('tools', None)
# Skills — extract IDs from message content (<$skillId|label> tags) so
# persisted chats work without relying on the frontend to send skill_ids.
@@ -2705,10 +2704,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
form_data['metadata'] = metadata
# When the caller provides an explicit `tools` key in the request body,
# skip all server-side tool resolution and pass the caller's tools through
# unchanged. Sending `tools: []` explicitly opts out of builtin injection.
if payload_tools is None:
# When the caller provides an explicit OpenAI-style `tools` array in the
# request body, skip all server-side tool resolution and pass the caller's
# tools through to the model unchanged.
if not payload_tools:
# Server side tools
tool_ids = metadata.get('tool_ids', None)
# Client side tools
@@ -2839,14 +2838,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if mcp_clients:
metadata['mcp_clients'] = mcp_clients
# Inject builtin tools for native function calling based on enabled features and model capability.
# Only inject when the request originates from the UI (identified by session_id).
# API callers don't expect hidden tools; they can explicitly request tools via tool_ids.
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get(
'builtin_tools', True
)
has_session = bool(metadata.get('session_id'))
if has_session and metadata.get('params', {}).get('function_calling') != 'legacy' and builtin_tools_enabled:
if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled:
# Add file context to user messages
chat_id = metadata.get('chat_id')
form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user)
@@ -2869,7 +2866,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# (e.g. pipe functions) can access all tools including MCP and builtins.
metadata['tools'] = tools_dict
if metadata.get('params', {}).get('function_calling') != 'legacy':
if metadata.get('params', {}).get('function_calling') == 'native':
# If the function calling is native, then call the tools function calling handler
form_data['tools'] = [
{'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values()
@@ -3535,19 +3532,18 @@ async def non_streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
},
)
@@ -3885,14 +3881,14 @@ async def streaming_chat_response_handler(response, ctx):
DETECT_CODE_INTERPRETER = (
bool(features.get('code_interpreter'))
and builtin_tools_meta.get('code_interpreter', True)
and await Config.get('code_interpreter.enable')
and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True)
and model_capabilities.get('code_interpreter', True)
and (
getattr(user, 'role', None) == 'admin'
or await has_permission(
getattr(user, 'id', ''),
'features.code_interpreter',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
)
)
)
@@ -4786,7 +4782,7 @@ async def streaming_chat_response_handler(response, ctx):
source_context = source_context.strip()
if source_context:
rag_content = await rag_template(
await Config.get('rag.template'),
request.app.state.config.RAG_TEMPLATE,
source_context,
user_message,
)
@@ -4980,7 +4976,7 @@ async def streaming_chat_response_handler(response, ctx):
""")
code = blocking_code + '\n' + code
if await Config.get('code_interpreter.engine') == 'pyodide':
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
ci_output = await event_caller(
{
'type': 'execute:python',
@@ -4992,21 +4988,21 @@ async def streaming_chat_response_handler(response, ctx):
},
}
)
elif await Config.get('code_interpreter.engine') == 'jupyter':
elif request.app.state.config.CODE_INTERPRETER_ENGINE == 'jupyter':
ci_output = await execute_code_jupyter(
await Config.get('code_interpreter.jupyter.url'),
request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
code,
(
await Config.get('code_interpreter.jupyter.auth_token')
if await Config.get('code_interpreter.jupyter.auth') == 'token'
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token'
else None
),
(
await Config.get('code_interpreter.jupyter.auth_password')
if await Config.get('code_interpreter.jupyter.auth') == 'password'
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password'
else None
),
await Config.get('code_interpreter.jupyter.timeout'),
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
)
else:
ci_output = {'stdout': 'Code interpreter engine not configured.'}
@@ -5150,19 +5146,18 @@ async def streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
'title': title,
'url': f'{webui_url}/c/{metadata["chat_id"]}',
'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
},
)
-38
View File
@@ -69,44 +69,6 @@ def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str |
return True
def _host_matches_pattern(host: str, pattern: str) -> bool:
"""Match a hostname against a filter entry on DNS label boundaries.
`pattern` matches `host` when equal or a parent domain of it, so `corp.com`
matches `api.corp.com` but not `evilcorp.com`, and an IP literal matches only
itself. Avoids the raw-suffix confusion of a plain endswith.
"""
host = (host or '').strip().lower().rstrip('.')
pattern = (pattern or '').strip().lower().rstrip('.')
if not host or not pattern:
return False
return host == pattern or host.endswith('.' + pattern)
def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool:
"""Allow/block a hostname (or list of hostnames / resolved IPs) against a
WEB_FETCH_FILTER_LIST-style filter, matching on label boundaries.
Pass a parsed hostname, never a full URL: matching against a URL lets a path
component defeat the filter (e.g. ``https://blocked.example/x`` ends with ``/x``,
not the blocked host). Entries prefixed with ``!`` are blocked; the rest form an allowlist.
"""
if not filter_list:
return True
allow_list, block_list = get_allow_block_lists(filter_list)
hosts = [host] if isinstance(host, str) else list(host or [])
if allow_list:
if not any(_host_matches_pattern(h, allowed) for h in hosts for allowed in allow_list):
return False
if any(_host_matches_pattern(h, blocked) for h in hosts for blocked in block_list):
return False
return True
def get_message_list(messages_map, message_id):
"""
Reconstructs a list of messages in order up to the specified message_id.
+8 -20
View File
@@ -12,7 +12,6 @@ from open_webui.config import (
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.functions import get_function_models
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.functions import Functions
from open_webui.models.groups import Groups
from open_webui.models.models import Models
@@ -53,15 +52,14 @@ async def fetch_openai_models(request: Request, user: UserModel = None):
async def get_all_base_models(request: Request, user: UserModel = None):
config = await Config.get_many('openai.enable', 'ollama.enable')
openai_task = (
fetch_openai_models(request, user)
if config.get('openai.enable')
if request.app.state.config.ENABLE_OPENAI_API
else asyncio.sleep(0, result=[])
)
ollama_task = (
fetch_ollama_models(request, user)
if config.get('ollama.enable')
if request.app.state.config.ENABLE_OLLAMA_API
else asyncio.sleep(0, result=[])
)
function_task = get_function_models(request)
@@ -72,15 +70,10 @@ async def get_all_base_models(request: Request, user: UserModel = None):
async def get_all_models(request, refresh: bool = False, user: UserModel = None):
config = await Config.get_many(
'models.base_models_cache',
'evaluation.arena.enable',
'evaluation.arena.models',
)
if (
request.app.state.MODELS
and request.app.state.BASE_MODELS
and (config.get('models.base_models_cache') and not refresh)
and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
):
base_models = request.app.state.BASE_MODELS
else:
@@ -95,10 +88,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
return []
# Add arena models
if config.get('evaluation.arena.enable'):
if request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS:
arena_models = []
arena_config = config.get('evaluation.arena.models') or []
if len(arena_config) > 0:
if len(request.app.state.config.EVALUATION_ARENA_MODELS) > 0:
arena_models = [
{
'id': model['id'],
@@ -111,7 +103,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'owned_by': 'arena',
'arena': True,
}
for model in arena_config
for model in request.app.state.config.EVALUATION_ARENA_MODELS
]
else:
# Add default arena model
@@ -297,7 +289,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = await Config.get('models.default_metadata', {}) or {}
default_metadata = getattr(request.app.state.config, 'DEFAULT_MODEL_METADATA', None) or {}
if default_metadata:
for model in models:
@@ -376,11 +368,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
models_dict = {model['id']: model for model in models}
if isinstance(request.app.state.MODELS, RedisDict):
try:
request.app.state.MODELS.set(models_dict)
except Exception as e:
log.warning(f'Failed to update Redis model cache, using in-process cache: {e}')
request.app.state.MODELS = models_dict
request.app.state.MODELS.set(models_dict)
else:
request.app.state.MODELS = models_dict
+89 -147
View File
@@ -1,17 +1,18 @@
import base64
import copy
import fnmatch
import hashlib
import json
import logging
import mimetypes
import re
import secrets
import sys
import time
import urllib
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import Literal, Optional
import aiohttp
@@ -61,6 +62,7 @@ from open_webui.config import (
OAUTH_UPDATE_PICTURE_ON_LOGIN,
OAUTH_USERNAME_CLAIM,
WEBHOOK_URL,
AppConfig,
)
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
@@ -76,7 +78,6 @@ from open_webui.env import (
WEBUI_NAME,
)
from open_webui.models.auths import Auths
from open_webui.models.config import Config
from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
@@ -110,73 +111,31 @@ from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
OAUTH_RUNTIME_CONFIG = {
'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE),
'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP),
'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': (
'oauth.refresh_token.include_scope',
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
),
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': (
'oauth.merge_accounts_by_email',
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
),
'ENABLE_OAUTH_ROLE_MANAGEMENT': (
'oauth.enable_role_mapping',
ENABLE_OAUTH_ROLE_MANAGEMENT,
),
'ENABLE_OAUTH_GROUP_MANAGEMENT': (
'oauth.enable_group_mapping',
ENABLE_OAUTH_GROUP_MANAGEMENT,
),
'ENABLE_OAUTH_GROUP_CREATION': (
'oauth.enable_group_creation',
ENABLE_OAUTH_GROUP_CREATION,
),
'OAUTH_GROUP_DEFAULT_SHARE': (
'oauth.group_default_share',
OAUTH_GROUP_DEFAULT_SHARE,
),
'OAUTH_BLOCKED_GROUPS': ('oauth.blocked_groups', OAUTH_BLOCKED_GROUPS),
'OAUTH_ROLES_CLAIM': ('oauth.roles_claim', OAUTH_ROLES_CLAIM),
'OAUTH_SUB_CLAIM': ('oauth.sub_claim', OAUTH_SUB_CLAIM),
'OAUTH_GROUPS_CLAIM': ('oauth.group_claim', OAUTH_GROUPS_CLAIM),
'OAUTH_EMAIL_CLAIM': ('oauth.email_claim', OAUTH_EMAIL_CLAIM),
'OAUTH_PICTURE_CLAIM': ('oauth.picture_claim', OAUTH_PICTURE_CLAIM),
'OAUTH_USERNAME_CLAIM': ('oauth.username_claim', OAUTH_USERNAME_CLAIM),
'OAUTH_ALLOWED_ROLES': ('oauth.allowed_roles', OAUTH_ALLOWED_ROLES),
'OAUTH_ADMIN_ROLES': ('oauth.admin_roles', OAUTH_ADMIN_ROLES),
'OAUTH_ALLOWED_DOMAINS': ('oauth.allowed_domains', OAUTH_ALLOWED_DOMAINS),
'WEBHOOK_URL': ('webhook_url', WEBHOOK_URL),
'JWT_EXPIRES_IN': ('auth.jwt_expiry', JWT_EXPIRES_IN),
'OAUTH_UPDATE_PICTURE_ON_LOGIN': (
'oauth.update_picture_on_login',
OAUTH_UPDATE_PICTURE_ON_LOGIN,
),
'OAUTH_UPDATE_NAME_ON_LOGIN': (
'oauth.update_name_on_login',
OAUTH_UPDATE_NAME_ON_LOGIN,
),
'OAUTH_UPDATE_EMAIL_ON_LOGIN': (
'oauth.update_email_on_login',
OAUTH_UPDATE_EMAIL_ON_LOGIN,
),
'OAUTH_AUDIENCE': ('oauth.audience', OAUTH_AUDIENCE),
}
def _default_value(value):
return getattr(value, 'value', value)
async def get_oauth_runtime_config() -> SimpleNamespace:
keys = [key for key, _default in OAUTH_RUNTIME_CONFIG.values()]
stored = await Config.get_many(*keys)
values = {
name: stored.get(key, _default_value(default))
for name, (key, default) in OAUTH_RUNTIME_CONFIG.items()
}
return SimpleNamespace(**values)
auth_manager_config = AppConfig()
auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
auth_manager_config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
auth_manager_config.OAUTH_ALLOWED_ROLES = OAUTH_ALLOWED_ROLES
auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES
auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN
auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
# Conservative default when the provider omits both expires_in and expires_at.
@@ -464,8 +423,7 @@ async def get_oauth_client_info_with_dynamic_client_registration(
oauth_server_metadata = None
oauth_server_metadata_url = None
webui_url = await Config.get('webui.url')
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
oauth_client_metadata = OAuthClientMetadata(
client_name='Open WebUI',
@@ -477,13 +435,6 @@ async def get_oauth_client_info_with_dynamic_client_registration(
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
resource = resource_metadata.resource
# Prefer the resource-specific scopes from the Protected Resource Metadata
# (RFC 9728) over the AS's full scopes_supported catalog, for least
# privilege. Mirrors the static-credentials flow (#24690).
if resource_metadata.scopes_supported:
oauth_client_metadata.scope = ' '.join(resource_metadata.scopes_supported)
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session:
@@ -591,8 +542,7 @@ async def get_oauth_client_info_with_static_credentials(
oauth_server_metadata = None
oauth_server_metadata_url = None
webui_url = await Config.get('webui.url')
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
@@ -679,7 +629,7 @@ class OAuthClientManager:
'client_secret': oauth_client_info.client_secret,
'client_kwargs': {
'follow_redirects': True,
**({'timeout': int(OAUTH_CLIENT_TIMEOUT)} if OAUTH_CLIENT_TIMEOUT else {}),
**({'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}
@@ -711,7 +661,7 @@ class OAuthClientManager:
}
return self.clients[client_id]
async def ensure_client_from_config(self, client_id):
def ensure_client_from_config(self, client_id):
"""
Lazy-load an OAuth client from the current TOOL_SERVER_CONNECTIONS
config if it hasn't been registered on this node yet.
@@ -720,7 +670,7 @@ class OAuthClientManager:
return self.clients[client_id]['client']
try:
connections = await Config.get('tool_server.connections', [])
connections = getattr(self.app.state.config, 'TOOL_SERVER_CONNECTIONS', [])
except Exception:
connections = []
@@ -826,22 +776,22 @@ class OAuthClientManager:
return True
async def get_client(self, client_id):
def get_client(self, client_id):
if client_id not in self.clients:
await self.ensure_client_from_config(client_id)
self.ensure_client_from_config(client_id)
client = self.clients.get(client_id)
return client['client'] if client else None
async def get_client_info(self, client_id):
def get_client_info(self, client_id):
if client_id not in self.clients:
await self.ensure_client_from_config(client_id)
self.ensure_client_from_config(client_id)
client = self.clients.get(client_id)
return client['client_info'] if client else None
async def get_server_metadata_url(self, client_id):
client = await self.get_client(client_id)
def get_server_metadata_url(self, client_id):
client = self.get_client(client_id)
if not client:
return None
@@ -924,7 +874,6 @@ class OAuthClientManager:
Returns:
dict: New token data, or None if refresh failed
"""
auth_config = await get_oauth_runtime_config()
client_id = session.provider
token_data = session.token
@@ -933,14 +882,14 @@ class OAuthClientManager:
return None
try:
client = await self.get_client(client_id)
client = self.get_client(client_id)
if not client:
log.error(f'No OAuth client found for provider {client_id}')
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(await self.get_server_metadata_url(client_id)) as r:
async with session_http.get(self.get_server_metadata_url(client_id)) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get('token_endpoint')
@@ -957,7 +906,7 @@ class OAuthClientManager:
'client_id': client.client_id,
}
# RFC 8707: include resource indicator so refreshed tokens retain correct audience
client_info = await self.get_client_info(client_id)
client_info = self.get_client_info(client_id)
if client_info and client_info.resource:
refresh_data['resource'] = client_info.resource
@@ -968,7 +917,7 @@ class OAuthClientManager:
if (
hasattr(client, 'client_kwargs')
and client.client_kwargs.get('scope')
and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
and getattr(self.app.state.config, 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', False)
):
refresh_data['scope'] = client.client_kwargs['scope']
@@ -1001,13 +950,13 @@ class OAuthClientManager:
return None
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
client = await self.get_client(client_id)
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
if client is None:
raise HTTPException(404)
client_info = await self.get_client_info(client_id)
client_info = self.get_client_info(client_id)
if client_info is None:
# get_client registers client_info too
client_info = await self.get_client_info(client_id)
# ensure_client_from_config registers client_info too
client_info = self.get_client_info(client_id)
if client_info is None:
raise HTTPException(404)
@@ -1020,13 +969,13 @@ class OAuthClientManager:
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
async def handle_callback(self, request, client_id: str, user_id: str, response):
client = await self.get_client(client_id)
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
if client is None:
raise HTTPException(404)
error_message = None
try:
client_info = await self.get_client_info(client_id)
client_info = self.get_client_info(client_id)
# Note: Do NOT pass client_id/client_secret explicitly here.
# The Authlib client already has these configured during add_client().
@@ -1079,8 +1028,7 @@ class OAuthClientManager:
exc_info=True,
)
webui_url = await Config.get('webui.url')
redirect_url = (str(webui_url or request.base_url)).rstrip('/')
redirect_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
if error_message:
log.debug(error_message)
@@ -1247,7 +1195,7 @@ class OAuthManager:
if (
hasattr(client, 'client_kwargs')
and client.client_kwargs.get('scope')
and auth_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
):
refresh_data['scope'] = client.client_kwargs['scope']
@@ -1280,7 +1228,6 @@ class OAuthManager:
return None
async def get_user_role(self, user, user_data):
auth_config = await get_oauth_runtime_config()
user_count = await Users.get_num_users()
if user and user_count == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
@@ -1292,16 +1239,16 @@ class OAuthManager:
# default role here (not 'admin') — admin promotion happens
# race-safely *after* insert via get_num_users() == 1.
log.debug('First user bootstrap: using default role (admin promotion deferred to post-insert)')
return auth_config.DEFAULT_USER_ROLE
return auth_manager_config.DEFAULT_USER_ROLE
if auth_config.ENABLE_OAUTH_ROLE_MANAGEMENT:
if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT:
log.debug('Running OAUTH Role management')
oauth_claim = auth_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = []
# Default/fallback role if no matching roles are found
role = auth_config.DEFAULT_USER_ROLE
role = auth_manager_config.DEFAULT_USER_ROLE
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
@@ -1359,7 +1306,7 @@ class OAuthManager:
else:
if not user:
# If role management is disabled, use the default role for new users
role = auth_config.DEFAULT_USER_ROLE
role = auth_manager_config.DEFAULT_USER_ROLE
else:
# If role management is disabled, use the existing role for existing users
role = user.role
@@ -1367,12 +1314,11 @@ class OAuthManager:
return role
async def update_user_groups(self, user, user_data, default_permissions, db=None):
auth_config = await get_oauth_runtime_config()
log.debug('Running OAUTH Group management')
oauth_claim = auth_config.OAUTH_GROUPS_CLAIM
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
try:
blocked_groups = json.loads(auth_config.OAUTH_BLOCKED_GROUPS)
blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS)
except Exception as e:
log.exception(f'Error loading OAUTH_BLOCKED_GROUPS: {e}')
blocked_groups = []
@@ -1400,7 +1346,7 @@ class OAuthManager:
all_available_groups: list[GroupModel] = await Groups.get_all_groups(db=db)
# Create groups if they don't exist and creation is enabled
if auth_config.ENABLE_OAUTH_GROUP_CREATION:
if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
log.debug('Checking for missing groups to create...')
all_group_names = {g.name for g in all_available_groups}
groups_created = False
@@ -1417,7 +1363,7 @@ class OAuthManager:
name=group_name,
description=f"Group '{group_name}' created automatically via OAuth.",
permissions=default_permissions, # Use default permissions from function args
data={'config': {'share': auth_config.OAUTH_GROUP_DEFAULT_SHARE}},
data={'config': {'share': auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE}},
)
# Use determined creator ID (admin or fallback to current user)
created_group = await Groups.insert_new_group(creator_id, new_group_form, db=db)
@@ -1543,7 +1489,6 @@ class OAuthManager:
return '/user.png'
async def handle_login(self, request, provider):
auth_config = await get_oauth_runtime_config()
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
@@ -1555,15 +1500,14 @@ class OAuthManager:
)
kwargs = {}
if auth_config.OAUTH_AUDIENCE:
kwargs['audience'] = auth_config.OAUTH_AUDIENCE
if auth_manager_config.OAUTH_AUDIENCE:
kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE
if OAUTH_AUTHORIZE_PARAMS:
kwargs.update(OAUTH_AUTHORIZE_PARAMS)
return await client.authorize_redirect(request, redirect_uri, **kwargs)
async def handle_callback(self, request, provider, response, db=None):
auth_config = await get_oauth_runtime_config()
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
@@ -1617,8 +1561,8 @@ class OAuthManager:
id_token_claims = dict(user_data) if user_data else {}
if (
(not user_data)
or (auth_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_config.OAUTH_USERNAME_CLAIM not in user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
# Merge back ID token claims that the userinfo endpoint doesn't
@@ -1634,8 +1578,8 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Extract the "sub" claim, using custom claim if configured
if auth_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_config.OAUTH_SUB_CLAIM)
if auth_manager_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
@@ -1649,7 +1593,7 @@ class OAuthManager:
}
# Email extraction
email_claim = auth_config.OAUTH_EMAIL_CLAIM
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, '')
# We currently mandate that email addresses are provided
if not email:
@@ -1691,8 +1635,8 @@ class OAuthManager:
email = email.lower()
# If allowed domains are configured, check if the email domain is in the list
if (
'*' not in auth_config.OAUTH_ALLOWED_DOMAINS
and email.split('@')[-1] not in auth_config.OAUTH_ALLOWED_DOMAINS
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(f'OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}')
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -1701,7 +1645,7 @@ class OAuthManager:
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user:
# If the user does not exist, check if merging is enabled
if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = await Users.get_user_by_email(email, db=db)
if user:
@@ -1716,8 +1660,8 @@ class OAuthManager:
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
user.role = determined_role
if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN:
username_claim = auth_config.OAUTH_USERNAME_CLAIM
if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN:
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
if username_claim:
new_name = user_data.get(username_claim)
if new_name and new_name != user.name:
@@ -1725,8 +1669,8 @@ class OAuthManager:
user.name = new_name
log.debug(f'Updated name for user {user.email}')
if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
email_claim = auth_config.OAUTH_EMAIL_CLAIM
if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
if email_claim:
new_email = user_data.get(email_claim)
if new_email and new_email.lower() != user.email.lower():
@@ -1741,8 +1685,8 @@ class OAuthManager:
log.debug(f'Updated email for user {user.id}')
# Update profile picture if enabled and different from current
if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_config.OAUTH_PICTURE_CLAIM
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim,
@@ -1756,13 +1700,13 @@ class OAuthManager:
log.debug(f'Updated profile picture for user {user.email}')
else:
# If the user does not exist, check if signups are enabled
if auth_config.ENABLE_OAUTH_SIGNUP:
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = await Users.get_user_by_email(email, db=db)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_config.OAUTH_PICTURE_CLAIM
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim,
@@ -1771,7 +1715,7 @@ class OAuthManager:
picture_url = await self._process_picture_url(picture_url, token.get('access_token'))
else:
picture_url = '/user.png'
username_claim = auth_config.OAUTH_USERNAME_CLAIM
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
@@ -1798,10 +1742,10 @@ class OAuthManager:
await Users.update_user_role_by_id(user.id, 'admin', db=db)
user = await Users.get_user_by_id(user.id, db=db)
if auth_config.WEBHOOK_URL:
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_config.WEBHOOK_URL,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
'action': 'signup',
@@ -1810,8 +1754,7 @@ class OAuthManager:
},
)
default_group_id = await Config.get('ui.default_group_id')
await apply_default_group_assignment(default_group_id, user.id, db=db)
await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db)
else:
raise HTTPException(
@@ -1821,13 +1764,13 @@ class OAuthManager:
jwt_token = create_token(
data={'id': user.id},
expires_delta=parse_duration(auth_config.JWT_EXPIRES_IN),
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
await self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=await Config.get('user.permissions'),
default_permissions=request.app.state.config.USER_PERMISSIONS,
db=db,
)
@@ -1839,8 +1782,7 @@ class OAuthManager:
else ERROR_MESSAGES.DEFAULT('Error during OAuth process')
)
webui_url = await Config.get('webui.url')
redirect_base_url = (str(webui_url or request.base_url)).rstrip('/')
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
redirect_url = f'{redirect_base_url}/auth'
if error_message:
@@ -1850,7 +1792,7 @@ class OAuthManager:
response = RedirectResponse(url=redirect_url, headers=response.headers)
# Compute cookie expiry from JWT lifetime
expires_delta = parse_duration(auth_config.JWT_EXPIRES_IN)
expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN)
cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None
# Set the cookie token
-3
View File
@@ -141,9 +141,6 @@ def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate')
- start: keep first max_chars characters
- end: keep last max_chars characters
"""
if max_chars <= 0:
return ''
if not content or len(content) <= max_chars:
return content
+13 -24
View File
@@ -42,7 +42,6 @@ from open_webui.env import (
REDIS_KEY_PREFIX,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
@@ -346,7 +345,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
continue
tool_server_idx = tool_server_data.get('idx', 0)
connections = await Config.get('tool_server.connections', [])
connections = request.app.state.config.TOOL_SERVER_CONNECTIONS
if tool_server_idx >= len(connections):
log.warning(
f'Tool server index {tool_server_idx} out of range '
@@ -452,16 +451,6 @@ async def get_builtin_tools(
# Helper to check user-level feature permission (admins always pass)
user = extra_params.get('__user__', {})
config = await Config.get_many(
'rag.web.search.enable',
'image_generation.enable',
'images.edit.enable',
'code_interpreter.enable',
'notes.enable',
'channels.enable',
'automations.enable',
'calendar.enable',
)
async def has_user_permission(feature_key: str) -> bool:
if user.get('role') == 'admin':
@@ -469,7 +458,7 @@ async def get_builtin_tools(
return await has_permission(
user.get('id', ''),
f'features.{feature_key}',
await Config.get('user.permissions'),
request.app.state.config.USER_PERMISSIONS,
)
# Time utilities - available for date calculations
@@ -544,7 +533,7 @@ async def get_builtin_tools(
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
if (
is_builtin_tool_enabled('web_search')
and config.get('rag.web.search.enable')
and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False)
and get_model_capability('web_search')
and features.get('web_search')
and await has_user_permission('web_search')
@@ -554,7 +543,7 @@ async def get_builtin_tools(
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
if (
is_builtin_tool_enabled('image_generation')
and config.get('image_generation.enable')
and getattr(request.app.state.config, 'ENABLE_IMAGE_GENERATION', False)
and get_model_capability('image_generation')
and features.get('image_generation')
and await has_user_permission('image_generation')
@@ -562,7 +551,7 @@ async def get_builtin_tools(
builtin_functions.append(generate_image)
if (
is_builtin_tool_enabled('image_generation')
and config.get('images.edit.enable')
and getattr(request.app.state.config, 'ENABLE_IMAGE_EDIT', False)
and get_model_capability('image_generation')
and features.get('image_generation')
and await has_user_permission('image_generation')
@@ -572,7 +561,7 @@ async def get_builtin_tools(
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
if (
is_builtin_tool_enabled('code_interpreter')
and config.get('code_interpreter.enable')
and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True)
and get_model_capability('code_interpreter')
and features.get('code_interpreter')
and await has_user_permission('code_interpreter')
@@ -582,7 +571,7 @@ async def get_builtin_tools(
# Notes tools - search, view, create, and update user's notes
if (
is_builtin_tool_enabled('notes')
and config.get('notes.enable')
and getattr(request.app.state.config, 'ENABLE_NOTES', False)
and await has_user_permission('notes')
):
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
@@ -590,7 +579,7 @@ async def get_builtin_tools(
# Channels tools - search channels and messages
if (
is_builtin_tool_enabled('channels')
and config.get('channels.enable')
and getattr(request.app.state.config, 'ENABLE_CHANNELS', False)
and await has_user_permission('channels')
):
builtin_functions.extend(
@@ -613,7 +602,7 @@ async def get_builtin_tools(
# Automation tools - create and manage scheduled automations from chat
if (
is_builtin_tool_enabled('automations')
and config.get('automations.enable')
and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False)
and await has_user_permission('automations')
):
builtin_functions.extend(
@@ -623,7 +612,7 @@ async def get_builtin_tools(
# Calendar tools - search/create/update/delete events
if (
is_builtin_tool_enabled('calendar')
and config.get('calendar.enable')
and getattr(request.app.state.config, 'ENABLE_CALENDAR', False)
and await has_user_permission('calendar')
):
builtin_functions.extend(
@@ -969,7 +958,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
async def set_tool_servers(request: Request):
try:
request.app.state.TOOL_SERVERS = await get_tool_servers_data(await Config.get('tool_server.connections', []))
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 []
@@ -1066,7 +1055,7 @@ async def get_terminal_system_prompt(
async def set_terminal_servers(request: Request):
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
connections = await Config.get('terminal_server.connections', []) or []
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
# Build server configs compatible with get_tool_servers_data
# Terminal connections store id/name at top level; translate to info dict
@@ -1159,7 +1148,7 @@ async def get_terminal_tools(
- Loads specs from cache
- Builds callables that route through the terminal proxy
"""
connections = await Config.get('terminal_server.connections', []) or []
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get('id') == terminal_id), None)
if connection is None:
log.warning(f'Terminal server not found: {terminal_id}')
+2 -7
View File
@@ -30,7 +30,6 @@ fi
# ── Secret key setup ─────────────────────────────────────────────────────────
KEY_FILE="${WEBUI_SECRET_KEY_FILE:-.webui_secret_key}"
WEBUI_SECRET_KEY_LENGTH="${WEBUI_SECRET_KEY_LENGTH:-24}"
PORT="${PORT:-8080}"
HOST="${HOST:-0.0.0.0}"
@@ -39,11 +38,7 @@ if [[ -z "${WEBUI_SECRET_KEY:-}" && -z "${WEBUI_JWT_SECRET_KEY:-}" ]]; then
if [[ ! -f "$KEY_FILE" ]]; then
echo "Generating new WEBUI_SECRET_KEY..."
if ! [[ "$WEBUI_SECRET_KEY_LENGTH" =~ ^[1-9][0-9]*$ ]]; then
echo "WEBUI_SECRET_KEY_LENGTH must be a positive integer." >&2
exit 1
fi
head -c "$WEBUI_SECRET_KEY_LENGTH" /dev/random | base64 > "$KEY_FILE"
head -c 12 /dev/random | base64 > "$KEY_FILE"
fi
echo "Loading WEBUI_SECRET_KEY from ${KEY_FILE}"
@@ -110,4 +105,4 @@ exec env WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-}" \
--host "$HOST" \
--port "$PORT" \
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
"${ARGS[@]}"
"${ARGS[@]}"
+1 -4
View File
@@ -27,9 +27,6 @@ IF "%HOST%"=="" SET HOST=0.0.0.0
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS='*'"
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
IF "%WEBUI_SECRET_KEY_LENGTH%" == "" (
SET "WEBUI_SECRET_KEY_LENGTH=24"
)
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
@@ -39,7 +36,7 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
echo Generating WEBUI_SECRET_KEY
:: Generate a random value to use as a WEBUI_SECRET_KEY in case the user didn't provide one
SET /p WEBUI_SECRET_KEY=<nul
FOR /L %%i IN (1,1,%WEBUI_SECRET_KEY_LENGTH%) DO SET /p WEBUI_SECRET_KEY=<!random!>>%KEY_FILE%
FOR /L %%i IN (1,1,12) DO SET /p WEBUI_SECRET_KEY=<!random!>>%KEY_FILE%
echo WEBUI_SECRET_KEY generated
)
-55
View File
@@ -254,61 +254,6 @@ export const updateLdapServer = async (token: string = '', body: object) => {
return res;
};
export const getOAuthConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateOAuthConfig = async (token: string, body: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/oauth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const userSignIn = async (email: string, password: string) => {
let error = null;
-60
View File
@@ -65,38 +65,6 @@ export const unarchiveAllChats = async (token: string) => {
return res;
};
export const unshareAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/share/all`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const importChats = async (token: string, chats: object[]) => {
let error = null;
@@ -287,34 +255,6 @@ export const getArchivedChatList = async (
}));
};
export const getArchivedChatCount = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived/count`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getSharedChatList = async (token: string = '', page: number = 1, filter?: object) => {
let error = null;
-28
View File
@@ -214,34 +214,6 @@ export const searchFiles = async (
return res;
};
export const getFileCount = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFileById = async (token: string, id: string) => {
let error = null;
-86
View File
@@ -234,89 +234,3 @@ export const deleteFolderById = async (token: string, id: string, deleteContents
return res;
};
export const updateFolderAccessById = async (
token: string,
id: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getSharedFolders = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/shared`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return [];
});
if (error) {
throw error;
}
return res;
};
export const getSharedFolderChats = async (token: string, folderId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
-27
View File
@@ -85,33 +85,6 @@ export const updateUserDefaultPermissions = async (token: string, permissions: o
return res;
};
export const getUserDefaultPermissionsDefaults = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/default/permissions/defaults`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserRole = async (token: string, id: string, role: string) => {
let error = null;
@@ -150,8 +150,7 @@
selectedTab = 'overview';
chatList = [];
allChatsLoaded = false;
selectedRange = '30d';
loadOverview(30);
selectRange(selectedRange);
}
</script>
@@ -24,20 +24,12 @@
// Time period - persist in localStorage
let selectedPeriod =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
// Custom date range (YYYY-MM-DD) - persist in localStorage
let customStart =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomStart')) || '';
let customEnd =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomEnd')) || '';
$: periods = [
{ value: '24h', label: $i18n.t('Last 24 hours') },
{ value: '7d', label: $i18n.t('Last 7 days') },
{ value: '30d', label: $i18n.t('Last 30 days') },
{ value: '90d', label: $i18n.t('Last 90 days') },
{ value: 'all', label: $i18n.t('All time') },
{ value: 'custom', label: $i18n.t('Custom range') }
{ value: 'all', label: $i18n.t('All time') }
];
// User group filter
@@ -56,14 +48,6 @@
return { start: now - 30 * day, end: now };
case '90d':
return { start: now - 90 * day, end: now };
case 'custom': {
// Parse YYYY-MM-DD inputs; end date is inclusive (covers the full day)
const start = customStart ? Math.floor(new Date(customStart).getTime() / 1000) : null;
const end = customEnd
? Math.floor(new Date(customEnd).getTime() / 1000) + day - 1
: null;
return { start, end };
}
default:
return { start: null, end: null };
}
@@ -71,13 +55,7 @@
// Data
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
let modelStats: Array<{
model_id: string;
count: number;
unique_users?: number;
unique_chats?: number;
name?: string;
}> = [];
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
let tokenStats: Record<
@@ -162,13 +140,7 @@
loading = false;
};
// Reload when the period, group, or custom range changes.
// In custom mode, wait until both dates are set to avoid a half-specified query.
$: if (selectedPeriod === 'custom' ? customStart && customEnd : selectedPeriod) {
// reference customStart/customEnd so this block reruns when they change
customStart;
customEnd;
selectedGroupId;
$: if (selectedPeriod || selectedGroupId !== undefined) {
loadDashboard();
}
@@ -191,16 +163,6 @@
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
if (modelOrderBy === 'users') {
const aUsers = a.unique_users ?? 0;
const bUsers = b.unique_users ?? 0;
return modelDirection === 'asc' ? aUsers - bUsers : bUsers - aUsers;
}
if (modelOrderBy === 'chats') {
const aChats = a.unique_chats ?? 0;
const bChats = b.unique_chats ?? 0;
return modelDirection === 'asc' ? aChats - bChats : bChats - aChats;
}
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@@ -225,11 +187,7 @@
localStorage.setItem('analyticsPeriod', selectedPeriod);
}
// Persist custom date range
$: if (typeof localStorage !== 'undefined') {
localStorage.setItem('analyticsCustomStart', customStart);
localStorage.setItem('analyticsCustomEnd', customEnd);
}
onMount(loadDashboard);
</script>
<!-- Header with title and period selector -->
@@ -251,21 +209,6 @@
{/each}
</select>
{/if}
{#if selectedPeriod === 'custom'}
<input
type="date"
bind:value={customStart}
max={customEnd || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
/>
<span class="text-xs text-gray-400"></span>
<input
type="date"
bind:value={customEnd}
min={customStart || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
/>
{/if}
<select
bind:value={selectedPeriod}
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
@@ -396,42 +339,6 @@
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('users')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Users')}
{#if modelOrderBy === 'users'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('chats')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Chats')}
{#if modelOrderBy === 'chats'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
@@ -494,8 +401,6 @@
</div>
</td>
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_users ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_chats ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right"
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
>
@@ -508,7 +413,7 @@
{/each}
{#if sortedModels.length === 0}
<tr
><td colspan="7" class="px-3 py-2 text-center text-gray-400"
><td colspan="5" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td
></tr
>
@@ -46,9 +46,8 @@
try {
const result = await getLeaderboard(localStorage.token, searchQuery);
const statsMap = new Map((result?.entries ?? []).map((e) => [e.model_id, e]));
const modelMap = new Map(($models ?? []).map((m) => [m.id, m]));
const activeModels = $models
rankedModels = $models
.filter((m) => m?.owned_by !== 'arena' && !m?.info?.meta?.hidden)
.map((model) => {
const s = statsMap.get(model.id);
@@ -62,27 +61,12 @@
},
top_tags: s?.top_tags ?? []
};
})
.sort((a, b) => {
if (a.rating === '-') return 1;
if (b.rating === '-') return -1;
return b.rating - a.rating;
});
const evaluatedModels = (result?.entries ?? [])
.filter((entry) => !modelMap.has(entry.model_id))
.map((entry) => ({
id: entry.model_id,
name: entry.model_id,
rating: entry.rating,
stats: {
count: entry.won + entry.lost,
won: entry.won.toString(),
lost: entry.lost.toString()
},
top_tags: entry.top_tags ?? []
}));
rankedModels = [...activeModels, ...evaluatedModels].sort((a, b) => {
if (a.rating === '-') return 1;
if (b.rating === '-') return -1;
return b.rating - a.rating;
});
} catch (err) {
console.error('Leaderboard load failed:', err);
}
@@ -51,8 +51,7 @@
// Load history when model changes and modal is shown
$: if (show && model?.id) {
selectedRange = '30d';
loadHistory(30);
selectRange(selectedRange);
}
// Use top_tags from backend response (already computed)
+2 -4
View File
@@ -71,12 +71,12 @@
let functions = null;
let filteredItems = [];
const handleSearchInput = () => {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
setFilteredItems();
}, 300);
};
}
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
setFilteredItems();
@@ -363,7 +363,6 @@
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
placeholder={$i18n.t('Search Functions')}
/>
@@ -373,7 +372,6 @@
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
-36
View File
@@ -8,7 +8,6 @@
import { getBackendConfig } from '$lib/apis';
import Database from './Settings/Database.svelte';
import Authentication from './Settings/Authentication.svelte';
import General from './Settings/General.svelte';
import Pipelines from './Settings/Pipelines.svelte';
import Audio from './Settings/Audio.svelte';
@@ -38,7 +37,6 @@
const tabFromPath = pathParts[pathParts.length - 1];
selectedTab = [
'general',
'authentication',
'connections',
'models',
'evaluations',
@@ -96,24 +94,6 @@
'channels'
]
},
{
id: 'authentication',
title: 'Authentication',
route: '/admin/settings/authentication',
keywords: [
'authentication',
'auth',
'login',
'signup',
'ldap',
'oauth',
'oidc',
'sso',
'roles',
'groups',
'identity'
]
},
{
id: 'connections',
title: 'Connections',
@@ -328,7 +308,6 @@
</div>
<!-- {$i18n.t('General')} -->
<!-- {$i18n.t('Authentication')} -->
<!-- {$i18n.t('Connections')} -->
<!-- {$i18n.t('Models')} -->
<!-- {$i18n.t('Evaluations')} -->
@@ -365,19 +344,6 @@
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'authentication'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8 1.5A3.5 3.5 0 0 0 4.5 5v1H4a2 2 0 0 0-2 2v4.5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-.5V5A3.5 3.5 0 0 0 8 1.5ZM6 6V5a2 2 0 1 1 4 0v1H6Zm2 3a.75.75 0 0 0-.75.75v1a.75.75 0 0 0 1.5 0v-1A.75.75 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'connections'}
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -549,8 +515,6 @@
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'authentication'}
<Authentication />
{:else if selectedTab === 'connections'}
<Connections
on:save={() => {
@@ -1,776 +0,0 @@
<script lang="ts">
import { getBackendConfig } from '$lib/apis';
import {
getAdminConfig,
getLdapConfig,
getLdapServer,
getOAuthConfig,
updateLdapConfig,
updateLdapServer,
updateOAuthConfig,
updateAdminConfig
} from '$lib/apis/auths';
import { getGroups } from '$lib/apis/groups';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { config } from '$lib/stores';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
let adminConfig = null;
let groups = [];
let ENABLE_LDAP = false;
let LDAP_SERVER = {
label: '',
host: '',
port: '',
attribute_for_mail: 'mail',
attribute_for_username: 'uid',
app_dn: '',
app_dn_password: '',
search_base: '',
search_filters: '',
use_tls: false,
certificate_path: '',
ciphers: ''
};
let oauthConfig: any = null;
const updateLdapServerHandler = async () => {
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
if (!ENABLE_LDAP) return true;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(`${error}`);
return null;
});
return !!res;
};
const updateOAuthHandler = async () => {
if (!oauthConfig) return true;
const res = await updateOAuthConfig(localStorage.token, oauthConfig).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
oauthConfig = res;
}
return !!res;
};
const updateAdminHandler = async () => {
if (!adminConfig) return true;
const res = await updateAdminConfig(localStorage.token, adminConfig).catch((error) => {
toast.error(`${error}`);
return null;
});
return !!res;
};
const submitHandler = async () => {
const adminSaved = await updateAdminHandler();
const ldapSaved = await updateLdapServerHandler();
const oauthSaved = await updateOAuthHandler();
if (adminSaved && ldapSaved && oauthSaved) {
toast.success($i18n.t('Settings saved successfully!'));
await config.set(await getBackendConfig());
}
};
onMount(async () => {
await Promise.all([
(async () => {
adminConfig = await getAdminConfig(localStorage.token);
})(),
(async () => {
groups = await getGroups(localStorage.token);
})(),
(async () => {
LDAP_SERVER = await getLdapServer(localStorage.token);
})(),
(async () => {
oauthConfig = await getOAuthConfig(localStorage.token).catch(() => null);
})()
]);
const ldapConfig = await getLdapConfig(localStorage.token);
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={submitHandler}
>
<div class="space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if adminConfig !== null}
<div class="mb-3">
<div class="mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('User Access')}</div>
<hr class="border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder={$i18n.t('Select a role')}
>
<option value="pending">{$i18n.t('pending')}</option>
<option value="user">{$i18n.t('user')}</option>
<option value="admin">{$i18n.t('admin')}</option>
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
<option value={''}>None</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
</div>
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Keys')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEYS} />
</div>
{#if adminConfig?.ENABLE_API_KEYS}
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
</div>
<Switch bind:state={adminConfig.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} />
</div>
{#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2 mb-2.5">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
</div>
<input
class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEYS_ALLOWED_ENDPOINTS}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<a
href="https://docs.openwebui.com/reference/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
</a>
</div>
</div>
{/if}
{/if}
<div class=" mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={adminConfig.JWT_EXPIRES_IN}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
{#if adminConfig.JWT_EXPIRES_IN === '-1'}
<div class="mt-2 text-xs">
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-3 py-2"
>
<div>
<span class=" font-medium">{$i18n.t('Warning')}:</span>
<span
><a
href="https://docs.openwebui.com/reference/env-configuration#jwt_expires_in"
target="_blank"
class=" underline"
>{$i18n.t('No expiration can pose security risks.')}
</a></span
>
</div>
</div>
</div>
{/if}
</div>
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Pending Accounts')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Show Admin Details in Account Pending Overlay')}
</div>
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
</div>
{#if adminConfig.SHOW_ADMIN_DETAILS}
<div class="mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Admin Contact Email')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="email"
placeholder={$i18n.t('Leave empty to use first admin user')}
bind:value={adminConfig.ADMIN_EMAIL}
/>
</div>
</div>
{/if}
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Title')}
</div>
<Textarea
placeholder={$i18n.t(
'Enter a title for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_TITLE}
/>
</div>
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Content')}
</div>
<Textarea
placeholder={$i18n.t(
'Enter content for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_CONTENT}
/>
</div>
</div>
{/if}
<div class=" space-y-3">
<div class="mt-2 space-y-2 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('LDAP')}</div>
<div class="mt-1">
<Switch bind:state={ENABLE_LDAP} />
</div>
</div>
{#if ENABLE_LDAP}
<div class="flex flex-col gap-1">
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Label')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server label')}
bind:value={LDAP_SERVER.label}
/>
</div>
<div class="w-full"></div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Host')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server host')}
bind:value={LDAP_SERVER.host}
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Port')}
</div>
<Tooltip
placement="top-start"
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
className="w-full"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
type="number"
placeholder={$i18n.t('Enter server port')}
bind:value={LDAP_SERVER.port}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN')}
</div>
<Tooltip
content={$i18n.t('The Application Account DN you bind with for search')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
</Tooltip>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
required={false}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Username')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the username that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: sAMAccountName or uid or userPrincipalName')}
bind:value={LDAP_SERVER.attribute_for_username}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Base')}
</div>
<Tooltip content={$i18n.t('The base to search for users')} placement="top-start">
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
bind:value={LDAP_SERVER.search_base}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Filters')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
bind:value={LDAP_SERVER.search_filters}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<a
class=" text-gray-300 font-medium underline"
href="https://ldap.com/ldap-filters/"
target="_blank"
>
{$i18n.t('Click here for filter guides.')}
</a>
</div>
<div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('TLS')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.use_tls} />
</div>
</div>
{#if LDAP_SERVER.use_tls}
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
{$i18n.t('Certificate Path')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
</div>
</div>
<div class="flex justify-between items-center text-xs">
<div class=" font-medium">{$i18n.t('Validate certificate')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.validate_cert} />
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Ciphers')}
</div>
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: ALL')}
bind:value={LDAP_SERVER.ciphers}
/>
</Tooltip>
</div>
<div class="w-full"></div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{#if oauthConfig}
<div class="mb-3">
<div class="mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('OAuth / OIDC')}</div>
<hr class="border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="pr-1.5">
<div class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Provider Name')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="SSO"
bind:value={oauthConfig.OAUTH_PROVIDER_NAME}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Provider URL')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
bind:value={oauthConfig.OPENID_PROVIDER_URL}
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Client ID')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter Client ID')}
bind:value={oauthConfig.OAUTH_CLIENT_ID}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Client Secret')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Client Secret')}
required={false}
outerClassName="flex flex-1 bg-transparent"
inputClassName="w-full text-sm py-0.5 bg-transparent"
bind:value={oauthConfig.OAUTH_CLIENT_SECRET}
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Redirect URI')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter Redirect URI')}
bind:value={oauthConfig.OPENID_REDIRECT_URI}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Scopes')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="openid email profile"
bind:value={oauthConfig.OAUTH_SCOPES}
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Email Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="email"
bind:value={oauthConfig.OAUTH_EMAIL_CLAIM}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Username Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="name"
bind:value={oauthConfig.OAUTH_USERNAME_CLAIM}
/>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Picture Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="picture"
bind:value={oauthConfig.OAUTH_PICTURE_CLAIM}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Sub Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="sub"
bind:value={oauthConfig.OAUTH_SUB_CLAIM}
/>
</div>
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Enable OAuth Signup')}
</div>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_SIGNUP} />
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Merge Accounts by Email')}
</div>
<Switch bind:state={oauthConfig.OAUTH_MERGE_ACCOUNTS_BY_EMAIL} />
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Auto Redirect')}
</div>
<Switch bind:state={oauthConfig.OAUTH_AUTO_REDIRECT} />
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Allowed Domains')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="* (all domains)"
bind:value={oauthConfig.OAUTH_ALLOWED_DOMAINS}
/>
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Enable Role Mapping')}
</div>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT} />
</div>
{#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Roles Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="roles"
bind:value={oauthConfig.OAUTH_ROLES_CLAIM}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Admin Roles')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="admin"
bind:value={oauthConfig.OAUTH_ADMIN_ROLES}
/>
</div>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Allowed Roles')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="*"
bind:value={oauthConfig.OAUTH_ALLOWED_ROLES}
/>
</div>
{/if}
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Enable Group Mapping')}
</div>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT} />
</div>
{#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT}
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Auto-Create Groups')}
</div>
<Switch bind:state={oauthConfig.ENABLE_OAUTH_GROUP_CREATION} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Group Claim')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder="groups"
bind:value={oauthConfig.OAUTH_GROUP_CLAIM}
/>
</div>
<div class="w-full">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Blocked Groups')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Comma-separated group names')}
bind:value={oauthConfig.OAUTH_BLOCKED_GROUPS}
/>
</div>
</div>
{/if}
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Update Email')}
</div>
<Switch bind:state={oauthConfig.OAUTH_UPDATE_EMAIL_ON_LOGIN} />
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Update Name')}
</div>
<Switch bind:state={oauthConfig.OAUTH_UPDATE_NAME_ON_LOGIN} />
</div>
<div class="flex w-full justify-between pr-2">
<div class="self-center text-xs font-medium">
{$i18n.t('Update Picture')}
</div>
<Switch bind:state={oauthConfig.OAUTH_UPDATE_PICTURE_ON_LOGIN} />
</div>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
@@ -41,7 +41,7 @@
{#if config}
<div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Code Execution')}</div>
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
@@ -55,117 +55,112 @@
</div>
</div>
{#if config.ENABLE_CODE_EXECUTION}
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Execution Engine')}
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
>
{/each}
</select>
</div>
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
</div>
{/if}
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
autocomplete="off"
/>
</div>
<div class="flex items-center relative">
</div>
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class=" flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}
>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
>
{/each}
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class=" flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
{/if}
</div>
{/if}
</div>
@@ -3,8 +3,17 @@
import { v4 as uuidv4 } from 'uuid';
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths';
import {
getAdminConfig,
getLdapConfig,
getLdapServer,
updateAdminConfig,
updateLdapConfig,
updateLdapServer
} from '$lib/apis/auths';
import { getBanners, setBanners } from '$lib/apis/configs';
import { getGroups } from '$lib/apis/groups';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
@@ -20,17 +29,35 @@
export let saveHandler: Function;
let updateAvailable = false;
let updateAvailable = null;
let version = {
current: WEBUI_VERSION,
latest: WEBUI_VERSION
current: '',
latest: ''
};
let adminConfig = null;
let webhookUrl = '';
let groups = [];
let banners: Banner[] = [];
// LDAP
let ENABLE_LDAP = false;
let LDAP_SERVER = {
label: '',
host: '',
port: '',
attribute_for_mail: 'mail',
attribute_for_username: 'uid',
app_dn: '',
app_dn_password: '',
search_base: '',
search_filters: '',
use_tls: false,
certificate_path: '',
ciphers: ''
};
const checkForVersionUpdates = async () => {
updateAvailable = null;
version = await getVersionUpdates(localStorage.token).catch((error) => {
@@ -46,6 +73,17 @@
console.info(updateAvailable);
};
const updateLdapServerHandler = async () => {
if (!ENABLE_LDAP) return;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('LDAP server updated'));
}
};
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
@@ -53,6 +91,8 @@
const updateHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
const res = await updateAdminConfig(localStorage.token, adminConfig);
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
await updateLdapServerHandler();
await updateBanners();
@@ -66,6 +106,10 @@
};
onMount(async () => {
if ($config?.features?.enable_version_update_check) {
checkForVersionUpdates();
}
await Promise.all([
(async () => {
adminConfig = await getAdminConfig(localStorage.token);
@@ -73,10 +117,19 @@
(async () => {
webhookUrl = await getWebhookUrl(localStorage.token);
})(),
(async () => {
LDAP_SERVER = await getLdapServer(localStorage.token);
})(),
(async () => {
groups = await getGroups(localStorage.token);
})()
]);
banners = [...$_banners];
const ldapConfig = await getLdapConfig(localStorage.token);
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
banners = await getBanners(localStorage.token);
});
</script>
@@ -247,6 +300,395 @@
</div>
</div>
<div class="mb-3">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Authentication')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder={$i18n.t('Select a role')}
>
<option value="pending">{$i18n.t('pending')}</option>
<option value="user">{$i18n.t('user')}</option>
<option value="admin">{$i18n.t('admin')}</option>
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
<option value={''}>None</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Show Admin Details in Account Pending Overlay')}
</div>
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
</div>
{#if adminConfig.SHOW_ADMIN_DETAILS}
<div class="mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Admin Contact Email')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="email"
placeholder={$i18n.t('Leave empty to use first admin user')}
bind:value={adminConfig.ADMIN_EMAIL}
/>
</div>
</div>
{/if}
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Title')}
</div>
<Textarea
placeholder={$i18n.t(
'Enter a title for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_TITLE}
/>
</div>
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Content')}
</div>
<Textarea
placeholder={$i18n.t(
'Enter content for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_CONTENT}
/>
</div>
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Keys')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEYS} />
</div>
{#if adminConfig?.ENABLE_API_KEYS}
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
</div>
<Switch bind:state={adminConfig.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} />
</div>
{#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2 mb-2.5">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
</div>
<input
class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEYS_ALLOWED_ENDPOINTS}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<a
href="https://docs.openwebui.com/reference/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
</a>
</div>
</div>
{/if}
{/if}
<div class=" mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={adminConfig.JWT_EXPIRES_IN}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
{#if adminConfig.JWT_EXPIRES_IN === '-1'}
<div class="mt-2 text-xs">
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-3 py-2"
>
<div>
<span class=" font-medium">{$i18n.t('Warning')}:</span>
<span
><a
href="https://docs.openwebui.com/reference/env-configuration#jwt_expires_in"
target="_blank"
class=" underline"
>{$i18n.t('No expiration can pose security risks.')}
</a></span
>
</div>
</div>
</div>
{/if}
</div>
<div class=" space-y-3">
<div class="mt-2 space-y-2 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('LDAP')}</div>
<div class="mt-1">
<Switch bind:state={ENABLE_LDAP} />
</div>
</div>
{#if ENABLE_LDAP}
<div class="flex flex-col gap-1">
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Label')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server label')}
bind:value={LDAP_SERVER.label}
/>
</div>
<div class="w-full"></div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Host')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server host')}
bind:value={LDAP_SERVER.host}
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Port')}
</div>
<Tooltip
placement="top-start"
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
className="w-full"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
type="number"
placeholder={$i18n.t('Enter server port')}
bind:value={LDAP_SERVER.port}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN')}
</div>
<Tooltip
content={$i18n.t('The Application Account DN you bind with for search')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
</Tooltip>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
required={false}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Username')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the username that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t(
'Example: sAMAccountName or uid or userPrincipalName'
)}
bind:value={LDAP_SERVER.attribute_for_username}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Base')}
</div>
<Tooltip
content={$i18n.t('The base to search for users')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
bind:value={LDAP_SERVER.search_base}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Filters')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
bind:value={LDAP_SERVER.search_filters}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<a
class=" text-gray-300 font-medium underline"
href="https://ldap.com/ldap-filters/"
target="_blank"
>
{$i18n.t('Click here for filter guides.')}
</a>
</div>
<div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('TLS')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.use_tls} />
</div>
</div>
{#if LDAP_SERVER.use_tls}
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
{$i18n.t('Certificate Path')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
</div>
</div>
<div class="flex justify-between items-center text-xs">
<div class=" font-medium">{$i18n.t('Validate certificate')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.validate_cert} />
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Ciphers')}
</div>
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: ALL')}
bind:value={LDAP_SERVER.ciphers}
/>
</Tooltip>
</div>
<div class="w-full"></div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="mb-3">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Features')}</div>
@@ -219,10 +219,6 @@
config = res;
}
if (!config) {
return;
}
if (config.ENABLE_IMAGE_GENERATION) {
getModels();
}
@@ -25,7 +25,6 @@
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_AUTOCOMPLETE_GENERATION: true,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE: '',
TAGS_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_TAGS_GENERATION: true,
ENABLE_SEARCH_QUERY_GENERATION: true,
@@ -373,21 +372,6 @@
/>
</Tooltip>
</div>
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Autocomplete Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
{/if}
<div class="mb-2.5">
+7 -9
View File
@@ -221,15 +221,13 @@
{/if}
</div>
{#if showDefaultPermissionsModal}
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
{/if}
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
<button
class="flex items-center justify-between rounded-lg w-full transition mt-4"
@@ -10,11 +10,9 @@
import Users from './Users.svelte';
import GroupPreviewPanel from './GroupPreviewPanel.svelte';
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
@@ -33,7 +31,6 @@
let selectedTab = 'general';
let loading = false;
let showDeleteConfirmDialog = false;
let showResetConfirmDialog = false;
let userCount = 0;
@@ -59,29 +56,6 @@
show = false;
};
const resetToDefaultsHandler = async () => {
try {
// For user groups: reset to current global default permissions
// For default permissions modal: reset to stock/env-var configuration
const defaults = custom
? await getUserDefaultPermissions(localStorage.token)
: await getUserDefaultPermissionsDefaults(localStorage.token);
if (defaults) {
permissions = {
workspace: { ...DEFAULT_PERMISSIONS.workspace, ...defaults.workspace },
sharing: { ...DEFAULT_PERMISSIONS.sharing, ...defaults.sharing },
access_grants: { ...DEFAULT_PERMISSIONS.access_grants, ...defaults.access_grants },
chat: { ...DEFAULT_PERMISSIONS.chat, ...defaults.chat },
features: { ...DEFAULT_PERMISSIONS.features, ...defaults.features },
settings: { ...DEFAULT_PERMISSIONS.settings, ...defaults.settings }
};
toast.success($i18n.t('Permissions reset to defaults'));
}
} catch (error) {
toast.error(`${error}`);
}
};
const init = () => {
if (group) {
name = group.name;
@@ -120,17 +94,6 @@
}}
/>
<ConfirmDialog
bind:show={showResetConfirmDialog}
title={$i18n.t('Reset to Defaults')}
message={$i18n.t(
'Are you sure you want to reset all permissions to their default values? You will still need to save to apply the changes.'
)}
on:confirm={() => {
resetToDefaultsHandler();
}}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
@@ -287,29 +250,7 @@
</div>
{#if ['general', 'permissions'].includes(selectedTab)}
<div class="flex justify-between items-center pt-3 text-sm font-medium gap-1.5">
<div>
{#if selectedTab === 'permissions'}
<Tooltip
content={custom
? $i18n.t(
'Reset group permissions to match the current default user permissions'
)
: $i18n.t('Reset all permissions to their initial configuration values')}
>
<button
class="text-sm font-normal text-gray-500 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition hover:underline"
type="button"
on:click={() => {
showResetConfirmDialog = true;
}}
>
{$i18n.t('Reset to Defaults')}
</button>
</Tooltip>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
@@ -393,23 +393,6 @@
</div>
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Folders Sharing')}
</div>
<Switch bind:state={permissions.sharing.folders} />
</div>
{#if defaultPermissions?.sharing?.folders && !permissions.sharing.folders}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{#if permissions.chat.share}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
@@ -700,22 +683,6 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Import')}
</div>
<Switch bind:state={permissions.chat['import']} />
</div>
{#if defaultPermissions?.chat?.import && !permissions.chat['import']}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
@@ -1001,22 +968,6 @@
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('User Webhooks')}
</div>
<Switch bind:state={permissions.features.webhooks} />
</div>
{#if defaultPermissions?.features?.webhooks && !permissions.features.webhooks}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
@@ -84,16 +84,13 @@
getUserList();
}
const handleSearchInput = () => {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
if (page !== 1) {
page = 1;
} else {
getUserList();
}
page = 1;
getUserList();
}, 300);
};
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
@@ -109,7 +106,6 @@
<input
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
placeholder={$i18n.t('Search')}
/>
</div>
@@ -100,16 +100,13 @@
}
};
const handleSearchInput = () => {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
if (page !== 1) {
page = 1;
} else {
getUserList();
}
page = 1;
getUserList();
}, 300);
};
}
$: if (page !== null && orderBy !== null && direction !== null) {
getUserList();
@@ -213,7 +210,6 @@
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
@@ -15,6 +15,7 @@
import Pagination from '$lib/components/common/Pagination.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@@ -78,14 +79,13 @@
}
};
const handleSearchInput = () => {
if (channel === null) return;
// Debounce only query changes
$: if (query !== undefined && channel !== null) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
getUserList();
}, 300);
};
}
// Immediate response to page/sort changes
$: if (channel !== null && page && orderBy && direction) {
@@ -143,7 +143,6 @@
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
placeholder={$i18n.t('Search')}
/>
</div>
@@ -117,6 +117,10 @@
.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
];
} else {
if (userSuggestions) {
getUserList();
}
if (modelSuggestions) {
_models = [
...$models
@@ -128,7 +128,6 @@
{message}
{channel}
{thread}
{id}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
@@ -47,7 +47,6 @@
export let showUserProfile = true;
export let thread = false;
export let id = null;
export let replyToMessage = false;
export let disabled = false;
@@ -65,12 +64,6 @@
let edit = false;
let editedContent = null;
let showDeleteConfirmDialog = false;
$: renderedMessageId = message ? (id ? `${id}-${message.id}` : message.id) : null;
$: replyToMessageId = message?.reply_to_message
? id
? `${id}-${message.reply_to_message.id}`
: message.reply_to_message.id
: null;
// Swipe-to-reply state
let swipeStartX = 0;
@@ -184,7 +177,7 @@
{/if}
<div
id="message-{renderedMessageId}"
id="message-{message.id}"
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
? className
: `px-5 ${
@@ -321,7 +314,7 @@
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${replyToMessageId}`
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -361,7 +354,7 @@
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown
id={`${renderedMessageId}-reply-to`}
id={`${message.id}-reply-to`}
content={message?.reply_to_message?.content}
allowEmbeds={false}
/>
@@ -372,7 +365,7 @@
<div
class=" flex w-full message-{message.id} "
id="message-{renderedMessageId}"
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 mr-1 w-9`}>
@@ -532,7 +525,7 @@
<Skeleton />
{:else}
<Markdown
id={renderedMessageId}
id={message.id}
content={message.content}
paragraphTag="span"
allowEmbeds={!!message?.meta?.model_id}
@@ -28,5 +28,5 @@
</button>
</LinkPreview.Trigger>
<UserStatusLinkPreview id={user?.id} {openPreview} {side} {align} {sideOffset} />
<UserStatusLinkPreview id={user?.id} {side} {align} {sideOffset} />
</LinkPreview.Root>
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
import { LinkPreview } from 'bits-ui';
const i18n = getContext('i18n');
@@ -7,35 +7,21 @@
import UserStatus from './UserStatus.svelte';
export let id: string | null = null;
export let openPreview = false;
export let id = null;
export let side = 'top';
export let align = 'start';
export let sideOffset = 6;
let user = null;
let requestedUserId: string | null = null;
const loadUser = async (userId: string) => {
requestedUserId = userId;
const loadedUser = await getUserInfoById(localStorage.token, userId).catch((error) => {
if (requestedUserId === userId) {
onMount(async () => {
if (id) {
user = await getUserInfoById(localStorage.token, id).catch((error) => {
console.error('Error fetching user by ID:', error);
}
return null;
});
if (requestedUserId === userId) {
user = loadedUser;
return null;
});
}
};
$: if (openPreview && id && id !== requestedUserId) {
loadUser(id);
}
});
</script>
{#if user}
+107 -125
View File
@@ -113,6 +113,7 @@
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte';
import { getBanners } from '$lib/apis/configs';
export let chatIdProp = '';
@@ -168,9 +169,6 @@
let chat = null;
let tags = [];
// Read-only when viewing someone else's chat (e.g. via shared folder access)
$: readOnly = chat != null && chat.user_id !== $user?.id;
let chatTasks = [];
let history = {
@@ -329,117 +327,107 @@
);
};
let settingDefaults = false;
const setDefaults = async () => {
if (settingDefaults) return;
settingDefaults = true;
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (!$skills) {
skills.set(await getSkills(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) {
return;
}
try {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (!$skills) {
skills.set(await getSkills(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) {
return;
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) {
// Set Default Tools
if (model?.info?.meta?.toolIds) {
const defaultIds = [
...new Set(
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
)
];
// Separate unauthenticated OAuth tools
const unauthed = [];
const authed = [];
for (const id of defaultIds) {
const tool = $tools.find((t) => t.id === id);
if (tool && tool.authenticated === false) {
const parts = id.split(':');
const serverId = parts.at(-1) ?? id;
const authType =
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
} else {
authed.push(id);
}
}
selectedToolIds = authed;
pendingOAuthTools = unauthed;
} else if ($settings?.tools) {
selectedToolIds = $settings.tools;
} else {
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) {
// Set Default Tools
if (model?.info?.meta?.toolIds) {
const defaultIds = [
...new Set(
[...(model?.info?.meta?.toolIds ?? [])].filter((id) =>
$tools.find((t) => t.id === id)
)
// Set Default Skills
if (model?.info?.meta?.skillIds) {
selectedSkillIds = [
...new Set(
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
($skills ?? []).find((s) => s.id === id && s.is_active)
)
];
)
];
} else {
selectedSkillIds = [];
}
// Separate unauthenticated OAuth tools
const unauthed = [];
const authed = [];
for (const id of defaultIds) {
const tool = $tools.find((t) => t.id === id);
if (tool && tool.authenticated === false) {
const parts = id.split(':');
const serverId = parts.at(-1) ?? id;
const authType =
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
} else {
authed.push(id);
}
}
selectedToolIds = authed;
pendingOAuthTools = unauthed;
} else if ($settings?.tools) {
selectedToolIds = $settings.tools;
} else {
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
model?.filters?.find((f) => f.id === id)
);
}
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (
model.info?.meta?.capabilities?.['image_generation'] &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
// Set Default Skills
if (model?.info?.meta?.skillIds) {
selectedSkillIds = [
...new Set(
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
($skills ?? []).find((s) => s.id === id && s.is_active)
)
)
];
} else {
selectedSkillIds = [];
if (
model.info?.meta?.capabilities?.['web_search'] &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
model?.filters?.find((f) => f.id === id)
);
}
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (
model.info?.meta?.capabilities?.['image_generation'] &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
if (
model.info?.meta?.capabilities?.['web_search'] &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
if (
model.info?.meta?.capabilities?.['code_interpreter'] &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
// Set Default Terminal — only if the referenced terminal actually exists
if (model?.info?.meta?.terminalId) {
const tid = model.info.meta.terminalId;
if (isTerminalAvailable(tid)) {
selectedTerminalId.set(tid);
}
if (
model.info?.meta?.capabilities?.['code_interpreter'] &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
// Set Default Terminal — only if the referenced terminal actually exists
if (model?.info?.meta?.terminalId) {
const tid = model.info.meta.terminalId;
if (isTerminalAvailable(tid)) {
selectedTerminalId.set(tid);
}
}
} finally {
settingDefaults = false;
}
};
@@ -805,6 +793,13 @@
if (p.url.pathname === '/') {
await tick();
initNewChat();
// Re-fetch banners on navigation to homepage so newly configured banners appear
try {
banners.set(await getBanners(localStorage.token).catch(() => []));
} catch (e) {
console.error('Failed to refresh banners:', e);
}
}
stopAudio();
@@ -2104,9 +2099,8 @@
: selectedModels;
// Create response messages for each selected model
// Build message_ids list: [{model_id, message_id}, ...]
// Uses an array instead of a dict to support duplicate model IDs in side-by-side chat.
const messageIdsList: Array<{ model_id: string; message_id: string }> = [];
// Build message_ids map: {model_id: assistant_message_id}
const messageIdsMap: Record<string, string> = {};
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
@@ -2138,7 +2132,7 @@
}
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
messageIdsList.push({ model_id: modelId, message_id: responseMessageId });
messageIdsMap[modelId] = responseMessageId;
}
}
history = history;
@@ -2184,7 +2178,7 @@
// Single request — backend fans out to all models
const primaryModelId = selectedModelIds[0];
const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0);
const primaryResponseMessageId = messageIdsList[0]?.message_id;
const primaryResponseMessageId = messageIdsMap[primaryModelId];
if (primaryModel && primaryResponseMessageId) {
const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId);
@@ -2200,7 +2194,7 @@
primaryResponseMessageId,
_chatId,
{
messageIdsList: selectedModelIds.length > 1 ? messageIdsList : undefined,
messageIdsMap: selectedModelIds.length > 1 ? messageIdsMap : undefined,
regenerationPrompt
}
);
@@ -2244,7 +2238,7 @@
}
}
if ($settings?.memory ?? $config?.features?.enable_memories ?? false) {
if ($settings?.memory ?? false) {
features = { ...features, memory: true };
}
@@ -2269,11 +2263,11 @@
responseMessageId,
_chatId,
{
messageIdsList,
messageIdsMap,
regenerationPrompt,
continueResponse = false
}: {
messageIdsList?: Array<{ model_id: string; message_id: string }>;
messageIdsMap?: Record<string, string>;
regenerationPrompt?: string | null;
continueResponse?: boolean;
} = {}
@@ -2477,7 +2471,7 @@
folder_id: $selectedFolder?.id ?? undefined,
id: responseMessageId,
...(messageIdsList ? { message_ids: messageIdsList } : {}),
...(messageIdsMap ? { message_ids: messageIdsMap } : {}),
parent_id: userMessage?.parentId ?? null,
user_message: userMessage,
...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}),
@@ -3033,7 +3027,6 @@
<FilesOverlay show={dragged} />
<Navbar
bind:this={navbarElement}
{readOnly}
chat={{
id: $chatId,
chat: {
@@ -3110,7 +3103,6 @@
<Messages
bind:this={messagesRef}
chatId={$chatId}
{readOnly}
bind:history
bind:autoScroll
bind:prompt
@@ -3134,16 +3126,7 @@
</div>
</div>
{#if readOnly}
<div class="pb-6 z-10">
<div
class="text-xs text-gray-400 dark:text-gray-500 text-center"
>
{$i18n.t('Read only')}
</div>
</div>
{:else}
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
<MessageInput
bind:this={messageInput}
{history}
@@ -3225,7 +3208,6 @@
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{/if}
{:else}
<div class="flex items-center h-full">
<Placeholder
@@ -58,6 +58,8 @@
import { deleteFileById } from '$lib/apis/files';
import { getChatById } from '$lib/apis/chats';
import { getSessionUser } from '$lib/apis/auths';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
@@ -1104,6 +1106,9 @@
dropzoneElement.addEventListener('drop', onDrop, true);
dropzoneElement.addEventListener('dragleave', onDragLeave);
}
tools.set(await getTools(localStorage.token));
skills.set(await getSkills(localStorage.token));
};
initialize();

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