688 Commits

Author SHA1 Message Date
Classic298
2d18727ab8 perf: build info log messages lazily so raising the log level actually saves work (#27837)
Raising GLOBAL_LOG_LEVEL to WARNING buys quieter output but not less work: 241 INFO call sites interpolate their payload into an f-string before the logging call gets to drop it. The heaviest is get_doc, which logs every chunk id and metadata dict in a collection, so on the full-context retrieval path that is the entire knowledge base, once per chat request.

That one line at WARNING, CPython 3.12:

| knowledge base | payload | before   | after   |
| -------------- | ------- | -------- | ------- |
| top-k of 3     | 1.2 kB  | 3.8 us   | 0.07 us |
| 500 chunks     | 201 kB  | 583.6 us | 0.08 us |
| 5000 chunks    | 2.0 MB  | 5.8 ms   | 0.15 us |

The lazy form log.info('query_doc:result %s %s', result.ids, result.metadatas) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. Output at INFO is byte-identical. Two sites that already built their message eagerly, one str concat and one % operator, move to the same lazy form.
2026-08-02 15:39:10 -05:00
Classic298
52cfb02c72 perf: build debug log messages lazily so disabled debug logs cost nothing (#27834)
GLOBAL_LOG_LEVEL defaults to INFO, so every log.debug(...) in the backend is discarded, but the message is built first: 187 call sites interpolate their payload into an f-string before the logging call runs, so the work happens on every request and the result is thrown away. The worst one sits in process_chat_payload and stringifies the whole request body, full conversation history included, once per chat completion.

That one line with DEBUG disabled, CPython 3.12:

| conversation | payload | before   | after   |
| ------------ | ------- | -------- | ------- |
| 4 messages   | 1.2 kB  | 3.4 us   | 0.07 us |
| 20 messages  | 17 kB   | 24.8 us  | 0.07 us |
| 60 messages  | 123 kB  | 216.6 us | 0.07 us |

The lazy form log.debug('form_data: %s', form_data) hands the payload to record.getMessage(), which the InterceptHandler only reaches once a record has passed the level check. With DEBUG enabled the emitted lines are byte-identical, f'{x=}' sites included: those map to %r. MistralLoader._debug_log callers get the same treatment, since that wrapper already forwards *args.
2026-07-31 19:09:01 -05:00
Timothy Jaeryang Baek
bb0f898b43 refac 2026-07-31 17:41:14 -04:00
Classic298
006a63e641 perf: use the orjson codec for the Anthropic passthrough request body (#27810)
`passthrough_anthropic_messages` in `main.py` serializes the full request payload with stdlib `json` before sending it upstream. It is the largest single serialization on that path, since the body carries the whole conversation.

It now goes through `JSONCodec`, which selects orjson when `ENABLE_ORJSON` is set. The result is passed to aiohttp as `data=`, which encodes `str` as UTF-8 and sets `Content-Length` from the encoded bytes. The payload originates from a parsed request dict, so it holds only JSON-native types, and the serialized string is never hashed, compared or persisted.

The remaining stdlib `json` calls in this module are left alone: two are a debug log line and a fixed Ollama unload payload, and one parses an upstream error body.

With `ENABLE_ORJSON` unset, which is the default, `JSONCodec` is stdlib `json` and this call site behaves exactly as before.
2026-07-31 17:26:07 -04:00
Timothy Jaeryang Baek
810378c0b8 refac 2026-07-27 19:39:36 -04:00
Timothy Jaeryang Baek
b6b16d5871 refac 2026-07-27 19:24:03 -04:00
Classic298
72fdf238a8 perf: optional orjson JSON codec behind ENABLE_ORJSON (#27583)
Swap the JSON encoder/decoder used across the backend from stdlib json to
orjson when ENABLE_ORJSON is set — HTTP request bodies, JSONResponse
bodies, upstream provider responses, SSE chunks, and socket.io/Redis
payloads.

The flag defaults to off, in which case the app uses stdlib json and
engineio's codec verbatim, so default behaviour is unchanged.

- json_codec exports JSONCodec (stdlib json or the orjson codec) and
  SOCKETIO_JSON (engineio's codec or the orjson codec); call sites import
  JSONCodec and stay implementation-agnostic
- apply_orjson_http_json() is a no-op when the flag is off, leaving
  starlette's Request.json / JSONResponse.render untouched
- the orjson codec falls back to the stdlib for inputs orjson rejects
  (non-str dict keys, ints beyond 64 bits, NaN literals)
- orjson is imported only when the flag is on
- FastAPI(default_response_class=...) is deliberately not used: an
  explicit default disables the Pydantic direct-to-bytes fast path for
  response_model routes
2026-07-27 03:45:37 -04:00
Timothy Jaeryang Baek
93a34bb25b refac 2026-07-27 02:59:58 -04:00
Timothy Jaeryang Baek
0576e8eeb5 refac 2026-07-27 02:42:36 -04:00
Classic298
f65f893ff1 perf: stop refetching the model row and user groups in the completion access check (#27378)
The chat completion entry point fetched the model row and then check_model_access immediately fetched the exact same row again. Inside the check, the direct grant lookup and every hop of the base-model chain each refetched the caller's group memberships, because neither call passed user_group_ids even though both AccessGrants.has_access and has_base_model_access already accept it.

check_model_access now takes an optional prefetched model_info (used only when its id matches the requested model, so stale callers cannot bypass the lookup) and resolves the caller's group ids once, sharing them across the direct check and the whole base-model chain. The group fetch is skipped entirely for the owner-with-no-base-chain case, which previously needed no groups either.

DB round trips for one completion-entry access check (non-owner model with one base-model hop):

| queries | before | after |
| --- | --- | --- |
| model row SELECTs | 3 | 2 |
| group membership SELECTs | 2 | 1 |

For deeper base-model chains the before column grows by one group SELECT per hop; the after column stays at one.

Functionally verified with stubbed model, group and grant accessors: owner fast path issues no group or grant queries; a non-owner with a base chain resolves groups once and passes the same set to every hop; a prefetched matching model_info skips the duplicate row fetch while a mismatched one is refetched; denial and unknown-model cases still raise; the arena path is unchanged.
2026-07-27 01:52:04 -04:00
Timothy Jaeryang Baek
305880f2e2 refac 2026-07-27 01:46:10 -04:00
Timothy Jaeryang Baek
20647bd2d5 chore: format 2026-07-27 00:12:47 -04:00
Timothy Jaeryang Baek
6f93ecd4fd refac 2026-07-26 23:49:03 -04:00
Timothy Jaeryang Baek
b7489bbc6c refac 2026-07-26 23:16:58 -04:00
Timothy Jaeryang Baek
ed663f16ec refac 2026-07-26 23:09:22 -04:00
Timothy Jaeryang Baek
aadab2f480 refac 2026-07-26 22:32:06 -04:00
Timothy Jaeryang Baek
71c4da8c06 refac 2026-07-26 21:12:14 -04:00
Timothy Jaeryang Baek
d484a2a99e refac 2026-07-26 21:07:20 -04:00
Timothy Jaeryang Baek
b81627b2c9 refac 2026-07-26 18:46:39 -04:00
G30
79695a1d14 fix: persist modelIdx so duplicate side-by-side models don't collapse on reload (#26980)
When the same model is selected multiple times in a side-by-side chat,
each response is created with a distinct modelIdx (0,1,2,3) that
identifies its column. The backend now owns message persistence, but it
built the assistant placeholders without modelIdx, so the field was
never saved. On reload MultiResponseMessages groups responses by
modelIdx and falls back to grouping by model id when modelIdx is
missing; with duplicate models that fallback lumps every response into
each column, so all columns render the first response (and show a bogus
'1/N' pager).

Send modelIdx with each message_ids entry from the frontend and persist
it on the assistant placeholders in both the new-chat and existing-chat
paths. The message_ids list is now forwarded for every send (not just
multi-model ones) so single-column regenerations in a duplicate-model
chat also keep their column identity across reloads.
2026-07-26 18:44:03 -04:00
G30
71f8b6d5b4 feat: add a master OAuth / OIDC enable toggle in Authentication settings (#26988)
The OAuth / OIDC section in Admin Settings > Authentication had no
enable/disable switch, unlike the LDAP section above it. Add one that
persists via the existing Save flow and actually gates OAuth sign-in,
mirroring how the LDAP toggle works.

- config: new ENABLE_OAUTH persistent config ('oauth.enable'), defaulting
  to True so existing deployments with a provider configured keep working.
- oauth: expose ENABLE_OAUTH via the OAuth runtime config and reject the
  login and callback handlers with 404 when it is disabled.
- /api/config: report no OAuth providers when disabled so the login page
  hides the OAuth buttons (and cannot auto-redirect), without clearing the
  admin's provider configuration.
- auths: expose ENABLE_OAUTH through the admin OAuth config get/update
  endpoints (OAuthConfigForm + OAUTH_CONFIG_KEYS).
- Authentication.svelte: bind the OAuth / OIDC header Switch to the
  persisted oauthConfig.ENABLE_OAUTH and collapse the section when off,
  matching the LDAP header (size, weight, alignment).
2026-07-26 18:43:21 -04:00
Classic298
e140d8f3cc fix: scope timer cancellation to the timer's owner (#27472)
The events:chat socket handler called the ownership-checked update for last_read_at, discarded the boolean it returns, and then cancelled the chat's pending timers regardless of the answer. cancel_timers_for_chat selected on the internal marker, the type, the parent chat id and the status, and never on the owner, so it matched rows belonging to any user. An authenticated user who knew another user's chat id could mark that chat read over their own socket session and silently cancel the owner's pending timers, and the owner got no notification: the scheduled action simply never fired.

The missing owner predicate also cut the other way in ordinary use. Because the query matched every timer sharing a parent chat id, one user reading a chat cancelled the timers of anyone else holding one on the same chat, so this was collateral damage as much as an attack.

cancel_timers_for_chat now requires a user_id and filters on it, which is the durable fix, and the socket handler returns early unless the ownership-checked update reports that the caller owns the chat. The parameter is required rather than defaulted so a later caller cannot reintroduce the unscoped query by omission. Both existing call sites already know the acting user. Timer rows are created with the same owner as the parent chat and the execution path already refuses to run one whose owner does not match, so scoping the cancellation the same way cannot strand a timer that would otherwise have fired.

One behaviour change worth noting: an administrator posting into another user's chat no longer cancels that user's chat.user_message timers, because the acting user is the administrator. The timer fires instead of being cancelled, which is the safe direction.
2026-07-26 18:12:44 -04:00
Classic298
c882222f68 fix: verify chat ownership on /api/chat/completed and /api/chat/actions (#27486)
Both routes read `chat_id` from the request body and passed it into `get_event_emitter` without checking the caller owns that chat. The emitter persists through `upsert_message_to_chat_by_id_and_message_id`, which resolves by primary key and takes no owner argument, so an invoked filter or action wrote into whichever chat the caller named. `/api/chat/completions` already performs this check; these two routes did not.

Adds `verify_chat_ownership`, called at the top of both handlers. It runs before the existing try block because the `except Exception` there catches HTTPException and would rewrite the 404 into a 400. Admins are exempt, matching the completions path, so deliberate cross-user operations keep working.

`local:` chat ids are allowed through: they are per-socket, the emitter suppresses database writes for them, and the socket emit targets the caller's own room. `channel:` chat ids are rejected instead. They reach the channel emitter, whose write only checks that the message belongs to the channel and never that the caller may write it, and the membership and write-access gate for channels exists solely on `/api/chat/completions`. No caller sends a `channel:` id to these two routes: the only frontend callers are in the regular chat UI, and the backend channel path dispatches through the completions handler.

Co-authored-by: manus-use <213290975+manus-use@users.noreply.github.com>
2026-07-26 17:39:11 -04:00
Timothy Jaeryang Baek
8cbb7f765c refac 2026-07-24 00:47:12 -04:00
Classic298
d0f759ce40 fix: capture uncompressed response bodies in audit logs (#27369)
CompressMiddleware was registered before AuditLoggingMiddleware. Starlette prepends on add_middleware, so the audit layer ended up outside compression and, at the REQUEST_RESPONSE level, recorded the zstd/brotli/gzip bytes of every response, decoded with errors='replace'. Any client that sent Accept-Encoding (i.e. every browser) therefore produced audit entries whose response_object was unreadable mojibake.

Registering the audit middleware before the compression middleware places it inside compression, so it observes the response body exactly as the route produced it while the client still receives the compressed stream.

Verified with a stacked ASGI harness: in the old order the captured body is not parseable; in the new order the captured body round-trips as the original JSON and the client response stays compressed.
2026-07-23 18:09:40 -05:00
Classic298
6b655689cc perf: cut repeated per-model work out of model list assembly
get_all_models runs on every models refresh and, without the base-models cache (off by default), on every /api/models request. Several of its costs multiplied by the model count for no reason:

- The active action and filter id sets were derived from get_functions_by_type, which loads full function rows including plugin source and validates them, only for the ids and is_global flags. A generalized column-only query now returns (id, is_global) tuples; the existing filter-specific helper delegates to it.
- Action priorities were computed inside the per-model sort key, constructing a pydantic Valves object per action per model; with global actions in every model's list that was models x actions constructions per refresh. Priorities are now memoized per action.
- Global action and filter item dicts were rebuilt per model from the same modules. The item lists are now built once per function and shallow-copied per model, keeping per-model dicts independent exactly as before (nested values were already shared).
- Deactivated base-model overrides were dropped with models.remove, a linear scan and shift per removal; removals are now collected and filtered out in one identity-based pass, preserving list.remove's exact object semantics.
- RedisDict.set fingerprinted the payload by serializing the already-serialized mapping a second time plus a sha256; a direct dict comparison against the last written mapping has the same skip semantics without re-serializing anything.
- /api/models did tag normalization and profile-image stripping for every model before access filtering discarded the invisible ones, and always evaluated a json.dumps debug f-string; the work now runs only on visible models and the debug line is gated on the log level. The duplicate-id dedup keeps its position before filtering so the effective-model semantics are unchanged.

Benchmark:

| metric | before | after |
| --- | --- | --- |
| model-cache fingerprint, 200 models | 45 us | 1.4 us |
| action priority Valves builds, 200 models x 4 global actions | 0.37 ms (800 builds) | 0.002 ms (4 builds) |
| function-table payload for id sets | full rows incl. source | (id, is_global) tuples |

Functionally verified: the column-only id query matches the full-row query for actions and filters including inactive exclusion, and the fingerprint skip logic writes on first set, skips identical payloads, updates plus deletes stale keys on change and clears on empty, against a scripted fake Redis.
2026-07-23 19:05:32 -04:00
Timothy Jaeryang Baek
bb12b1a18b refac 2026-07-23 04:16:14 -04:00
Timothy Jaeryang Baek
49e57f4e7e chore: format 2026-07-20 22:11:42 -04:00
Timothy Jaeryang Baek
2e857a82d7 refac 2026-07-16 02:47:51 -04:00
Timothy Jaeryang Baek
75894161e4 refac 2026-07-16 02:35:44 -04:00
Timothy Jaeryang Baek
c55e373b99 refac 2026-07-16 00:58:34 -04:00
Timothy Jaeryang Baek
b23ddeb280 refac 2026-07-16 00:30:44 -04:00
Timothy Jaeryang Baek
8f77533317 refac 2026-07-14 01:00:24 -04:00
Timothy Jaeryang Baek
7088d245bb refac 2026-07-14 00:10:28 -04:00
Timothy Jaeryang Baek
08dacd19da refac 2026-07-13 23:01:10 -04:00
Timothy Jaeryang Baek
51ff386fd6 refac 2026-07-13 23:00:54 -04:00
Timothy Jaeryang Baek
8e46450acd refac 2026-07-09 17:28:34 -05:00
Timothy Jaeryang Baek
90eca2ac25 refac 2026-07-01 03:37:35 -05:00
Timothy Jaeryang Baek
7f182ea063 refac 2026-07-01 03:35:27 -05:00
Timothy Jaeryang Baek
ff5cec43bd refac 2026-06-29 11:56:00 -05:00
Timothy Jaeryang Baek
754787f43d refac 2026-06-29 11:51:45 -05:00
Timothy Jaeryang Baek
815446d5bb refac 2026-06-29 11:33:49 -05:00
Classic298
75df948f34 feat: forward client User-Agent to model backends via {{USER_AGENT}} placeholder (#26333)
Adds a {{USER_AGENT}} custom-header placeholder that relays the inbound
client's User-Agent to upstream model backends, so providers see the real
client instead of Open WebUI's internal aiohttp UA. This makes upstream
usage/cost attribution and backend telemetry possible, and is opt-in
per-connection (no global flag): admins add {{USER_AGENT}} to a connection's
custom headers in Admin > Settings > Connections.

The placeholder is sourced from the live inbound request (with a metadata
fallback for detached RAG/tool calls), so it resolves on every prompt-sending
path, not just chat completions:

- OpenAI completions, Responses API, and proxy — all route through
  get_headers_and_cookies, which now passes the request into get_custom_headers.
- Anthropic Messages API (/api/v1/messages) — already covered, it delegates
  to the chat completion handler.
- Ollama (/api/chat, /v1/completions, /v1/chat/completions, /v1/messages,
  /v1/responses) — previously had no custom-header support at all; send_request
  now applies per-connection custom headers (with templating) for every
  Ollama prompt endpoint.

Custom headers are applied after the built-in user-info headers so explicit
admin-configured headers take precedence. The other existing placeholders
({{CHAT_ID}}, {{USER_ID}}, ...) now also work on the newly covered paths.

Frontend: the connection editor's Headers field is now shown for Ollama
connections too (previously gated to non-Ollama), so the placeholder can be
configured there.

Ref: open-webui/open-webui#26159
2026-06-29 05:58:19 -05:00
Timothy Jaeryang Baek
8927c9bb3d refac 2026-06-29 05:14:34 -05:00
Timothy Jaeryang Baek
67c9de8efd refac 2026-06-29 04:42:59 -05:00
Timothy Jaeryang Baek
7be009649a refac 2026-06-29 02:57:58 -05:00
Timothy Jaeryang Baek
ac3449cac9 refac 2026-06-29 02:26:27 -05:00
Timothy Jaeryang Baek
bc70696f4f refac 2026-06-29 00:40:28 -05:00
Timothy Jaeryang Baek
ef8c9c063c refac 2026-06-28 23:22:10 -05:00
Timothy Jaeryang Baek
464e703e47 refac 2026-06-28 22:50:31 -05:00