115 Commits
Author SHA1 Message Date
Timothy Jaeryang Baek 5c62cc0517 chore: format 2026-08-25 16:53:53 -04:00
Classic298andGitHub d198d950c6 perf: stop re-copying the response text on every stream save (#28821)
Every streamed delta saves a snapshot of the in-progress response so a reconnecting client can resume it, and each save rebuilt the assistant text from scratch. On the Chat Completions path that re-joined every accumulated chunk, including on saves carrying no new text, so a long answer followed by a large tool call re-joined the whole answer once per argument chunk. The Responses API path never collects those chunks and reads the text back out of the output items instead, where the blank check copied it in full every time.

The joined string is now kept and reused until another chunk arrives, since content_parts is only ever appended to; the nonlocal declaration that suggested otherwise was already dead and is dropped, and inlining the single-use helper removes an unreachable branch with it. The blank check in get_output_text now tests the text rather than allocating a stripped copy of it, which is equivalent for all twelve of its callers. Text streaming on the Chat Completions path is unchanged, since a text delta always appends before it saves.

| stream | before | after |
| --- | --- | --- |
| 20k-char answer, 2000 tool-argument chunks | 21.4 ms | 0.06 ms |
| Responses API, 40k deltas, 200k chars | 80.7 ms | 50.5 ms |

Without Redis nothing extra is retained, since the snapshot store already held that string; with Redis one copy of the response text stays alive while the stream runs.
2026-08-25 15:41:54 -04:00
Timothy Jaeryang BaekandFares a610d77137 refac
Co-Authored-By: Fares <26122914+faqeel@users.noreply.github.com>
2026-08-25 15:05:02 -04:00
Classic298andGitHub 2d2bcb5332 fix: long streamed lines no longer abort the response (#28114)
Some providers send one very large piece of a streamed answer in a single go: a long reasoning trace, a code execution result, a turn with many tool calls, or a response echo carrying a big tool list. Anything past 128 KB in one line killed the chat mid-answer with a misleading `400, message: Got more than 131072 bytes when reading`. Nothing was rejected upstream, that is our own reader giving up on an oversized line.

Open WebUI already had code that assembles lines itself with no such limit, but it only ran when CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE was set. Unset is the default, and in that case the raw capped reader was used instead, so a default install always broke. That path now always assembles lines, and the setting goes back to being what its name says: an optional cap, off by default. It applies to the Ollama stream as well, since both now share the same reader.

The assembly loop only splits once a line actually completes, because the old one re-concatenated and re-split the whole buffer on every network chunk. Without that, allowing long lines would have traded an error for multi-second event loop stalls.

| | 20 MB in one line | 200k small lines |
| --- | --- | --- |
| before | 4249 ms | 27.3 ms |
| after | 37 ms | 25.2 ms |
2026-08-25 12:16:37 -04:00
Classic298andGitHub e3e4bd87df refac: consolidate the web fetch address checks onto the request path (#27823)
* fix: apply the SSRF checks to redirect targets on every web fetch path

Two guards protect server-side fetches: a private-IP check and the operator's `WEB_FETCH_FILTER_LIST`. Neither reached a redirect hop on the aiohttp paths, and the filter list never reached one on the requests paths either.

aiohttp answers IP-literal hosts itself without consulting a resolver, so `_SSRFSafeResolver` was never invoked for a hop such as `http://169.254.169.254/` and the private-IP check simply did not run. With redirect following enabled, a submitted public URL that redirects to an IP literal reached loopback, RFC1918 and cloud-metadata addresses, and the response body was returned to the caller. The filter list was consulted only in `validate_url`, on the originally submitted URL, so a redirect to a filter-listed host was fetched without it ever being applied.

`_SSRFSafeResolver` is replaced by `_SSRFSafeConnector`, which hooks `_resolve_host` so the IP check also covers the IP-literal shortcut and both DNS cache paths. The filter list moves to a per-request hook on each transport, `connect()` for aiohttp and `send()` for the requests adapter, because those see the request destination: at the connection layer a proxied request presents the proxy's host, and a pooled connection skips resolution entirely. This covers every hop, including redirects, on all five aiohttp call sites and both requests sessions. The Playwright loader already validated each hop and is unchanged.

Both gaps required `AIOHTTP_CLIENT_ALLOW_REDIRECTS=true`, which is not the default.

Two behaviour changes for operators. The filter list now applies to redirect targets rather than only to submitted URLs. Under a forward proxy it is evaluated against the request destination instead of the proxy, which also fixes allowlist entries rejecting every fetch in proxied deployments.

* refac: match the web fetch filter list against resolved addresses

The filter list is now evaluated against the hostname together with the addresses it resolves to, at URL validation and on each connection, on both transports. An IPv6 address is also matched by the IPv4 address it carries.

* refac: screen outbound fetch addresses against reserved ranges ipaddress misses

`ipaddress.is_global` was the only test behind the web-fetch address check, and it answers a narrower question than "may we fetch this". Several special-purpose ranges are globally routable by registry while nothing on them is a legitimate destination, so they passed. Classification now screens those ranges on top of `is_global`, and applies the same screen to the IPv4 address embedded in an IPv6 transition encoding rather than only to the literal. All three checkpoints share the predicate, so they all inherit it.

The range list is the exact complement of what CPython's `ipaddress` already models, checked entry by entry against both IANA special-purpose registries. Prefixes IANA marks globally reachable are deliberately left out, so no real destination changes behaviour. Verified against 31 addresses covering every entry, their transition-encoded forms, and public controls in both families: 31/31 expected after, 18/31 before.

* refac: match web fetch filter entries that name an address or a range

A filter entry that parses as an address or a CIDR range is matched by containment rather than by DNS label suffix, so a range covers the addresses inside it and an address matches however it is spelled. A range entry previously matched nothing at all, silently.

The built-in list gains the special-purpose networks that ipaddress.is_global reports as reachable while nothing on them is a legitimate destination, so taking an address out of reach is a WEB_FETCH_FILTER_LIST change rather than a release. Those entries hold whether or not local web fetch is enabled; the private-address rule still follows the toggle.
2026-08-25 11:15:48 -04:00
Classic298andGitHub 043cf330d2 perf: throttle last_active_at writes by default (#28177)
Presence tracking writes each user's last_active_at on every authenticated request, every API key request and every websocket heartbeat. The throttle for it already exists but ships unset, and unset means no throttle at all, so a stock deployment pays one UPDATE plus COMMIT per user per request. The 30 second frontend heartbeat alone is 2 write transactions per minute per open tab, before any actual UI traffic.

Defaulting the throttle to 60 seconds collapses that to at most one write per user per worker per minute. Presence is only ever read at minute granularity, so nothing visible changes.

60 rather than the 300 to 500 the docs currently suggest, because a user counts as active for 3 minutes after their last write and that window is hardcoded in the backend and again in the frontend. Any interval at or above 180 seconds makes people who are actively using the instance drop out of the active user count. Letting the window follow the interval instead would need the value shipped to the client, so that is a separate change.

0 still disables the throttle, and now costs nothing at all: the decorator returns the undecorated function instead of a wrapper that re-checks a constant on every call.

Closes #28165
2026-08-24 17:53:31 -04:00
Timothy Jaeryang Baek f3f76095d1 refac 2026-08-23 02:34:08 -04:00
189c14fc4d fix: match both JSON text spellings when searching serialised JSON columns (#28399)
Three searches LIKE against cast(json_col AS text), which means they have to match
bytes a JSON encoder wrote. Encoders disagree on non-ASCII: stdlib escapes it to
\uXXXX, orjson writes it raw. Which one produced a row depends on the codec in force
when it was written, so any single pattern finds only half the table.

models.py hard-codes the stdlib spelling, with a comment asserting SQLite stores
JSON via json.dumps(ensure_ascii=True). Model.meta is a JSONField, which has
serialised through JSONCodec since ENABLE_ORJSON was introduced, so on that setting
it stores raw UTF-8 and the escaped pattern matches nothing: non-ASCII workspace
model tag search is broken today. prompts.py and automations.py hard-code the
opposite spelling and miss rows written the other way.

json_text_variants returns both spellings a string can take inside serialised JSON,
collapsing to one for ASCII, and the three call sites OR over them. Rows written
under either setting are now found under either setting, which also covers a
database holding a mix of the two.

Case handling is unchanged. models.py keeps matching non-ASCII tags case-sensitively
on SQLite, whose LOWER() is ASCII-only and would not fold the stored text the way
str.lower() folds the tag. ASCII tags collapse to a single variant and take exactly
the query they took before.

Verified on SQLite across every combination of codec-that-wrote-the-row and
codec-the-app-is-running, for an ASCII and a CJK tag, over all three call sites: 24
of 24 match, against 12 of 24 before. Quoting still bounds whole-tag matches, so
searching "weather" does not match a row tagged "weathervane".

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-17 01:24:05 -06:00
Timothy Jaeryang Baek b6dc70c93b refac 2026-08-17 00:16:07 -07:00
Timothy Jaeryang BaekandClassic298 3258330729 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-16 22:56:19 -07:00
Timothy Jaeryang Baek fa94a5ab24 refac 2026-08-13 21:36:41 -06:00
Timothy Jaeryang Baek 7d99b2716a refac 2026-08-13 19:59:11 -06:00
Classic298andGitHub 5c79ccc9e5 refactor: walk chat message history by map key (#28034)
`get_message_list` moves through `messages_map` by key but tracked each message's own `id` field, which the message body does not have to carry. Track the key instead.
2026-08-10 23:24:41 -06:00
Timothy Jaeryang Baek 11739a2de8 refac 2026-08-10 19:41:05 -06:00
Timothy Jaeryang Baek a33fa05adc refac 2026-08-10 00:19:52 -06:00
Classic298andGitHub 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
Timothy Jaeryang Baek bb0f898b43 refac 2026-07-31 17:41:14 -04:00
Timothy Jaeryang Baek ba556bd8f0 refac 2026-07-27 02:49:08 -04:00
Timothy Jaeryang Baek dd86b984bd refac 2026-07-27 00:55:16 -04:00
Timothy Jaeryang Baek 20647bd2d5 chore: format 2026-07-27 00:12:47 -04:00
Classic298andGitHub 18719fef9c fix: malformed WEB_FETCH_FILTER_LIST entry blocking all web fetches (#26910)
Docker compose list-form environment syntax passes quotes through verbatim, so WEB_FETCH_FILTER_LIST="" reaches the backend as two literal quote characters rather than an empty string. Config parsing turned that into the filter entry '""', which has no "!" prefix and therefore landed in the allow list. A non-empty allow list requires every host to match one of its entries, and a quotes-only pattern can never match a hostname, so every fetch_url and web loader request was rejected with "URL blocked by filter list" and surfaced to the user as "The URL you provided is invalid".

get_allow_block_lists now strips surrounding quote characters from each entry and drops entries that are empty after normalisation. Quoted but otherwise valid entries such as "example.com" or !"example.com" now behave as their unquoted forms, and garbage entries no longer convert the default blocklist into a match-nothing allowlist that blocks everything.

Fixes #26908
2026-07-24 01:27:24 -05:00
Timothy Jaeryang Baek 1f5b0d816f refac 2026-07-24 01:19:28 -04:00
Timothy Jaeryang Baek 43e7eefa95 refac 2026-07-23 22:45:54 -04:00
Timothy Jaeryang Baek 517cd8d102 refac 2026-06-29 13:03:14 -05:00
Timothy Jaeryang Baek 75db531c12 refac 2026-06-29 12:16:58 -05:00
087878ce84 Match WEB_FETCH_FILTER_LIST on hostnames with label boundaries, not URL suffix (CWE-693) (#25949)
is_string_allowed does endswith() matching and was called with the full URL
(retrieval/web/utils.py) against WEB_FETCH_FILTER_LIST, so a blocklisted host with any
path (https://blocked.example/x) ended with /x, not the host, and slipped through; the
allowlist direction false-rejected legitimate URLs and admitted attacker URLs ending in
an allowed string. The same endswith caused label confusion at the hostname call site
(retrieval/web/main.py): corp.com matched evilcorp.com, 10.0.0.1 matched 110.0.0.1.

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

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

Co-authored-by: addcontent <59762500+addcontent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:53:08 +02:00
Timothy Jaeryang Baek 6fce92aa12 chore: format 2026-06-01 13:56:55 -07:00
Timothy Jaeryang BaekandZixin Yu e3ab4bd212 refac
Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
2026-06-01 12:37:34 -07:00
Timothy Jaeryang Baek cfa6908d57 refac 2026-05-19 22:25:39 +04:00
Timothy Jaeryang Baek 6d0295588e refac: modernize type annotations (PEP 604 / PEP 585) 2026-05-12 17:10:15 +09:00
Timothy Jaeryang Baek 3ccf263b10 refac 2026-05-09 15:46:33 +09:00
Timothy Jaeryang Baek f48b8ffbf0 refac 2026-04-24 18:38:57 +09:00
tcx4c70andGitHub f6bd08c852 fix(utils): Switch throttle decorator to async (#23979)
After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2026-04-24 14:39:45 +09:00
Timothy Jaeryang Baek e88e565ab4 refac 2026-04-21 13:18:54 +09:00
Timothy Jaeryang Baek 3dd8255816 refac 2026-04-17 12:37:44 +09:00
Timothy Jaeryang Baek 37658fd541 refac 2026-04-14 01:17:39 -05:00
Timothy Jaeryang Baek c47dd7b771 refac 2026-04-12 17:22:06 -05:00
Algorithm5838andGitHub 98570d3547 perf: single yield per line in stream chunks handler (#23266) 2026-03-31 18:42:52 -05:00
Timothy Jaeryang Baek 631bd20c35 refac 2026-03-24 17:46:33 -05:00
Timothy Jaeryang Baek f7e07f3ca1 chore: format 2026-03-24 06:07:20 -05:00
Timothy Jaeryang Baek ade617efa8 refac 2026-03-24 04:49:48 -05:00
Timothy Jaeryang Baek 1c25b06dca refac 2026-03-23 19:46:24 -05:00
Timothy Jaeryang Baek ea515fa26e refac 2026-03-21 19:17:21 -05:00
Timothy Jaeryang Baek de3317e26b refac 2026-03-17 17:58:01 -05:00
Timothy Jaeryang Baek e34ed72e1e refac 2026-03-11 15:33:24 -05:00
Timothy Jaeryang Baek 97cc94756e chore: bump 2026-03-08 19:00:50 -05:00
Timothy Jaeryang Baek 1364df0913 refac 2026-03-08 18:26:36 -05:00
Timothy Jaeryang Baek a677b212d9 refac 2026-02-27 16:03:12 -06:00
Timothy Jaeryang Baek 179a4ad9ea refac 2026-02-27 16:01:57 -06:00
Timothy Jaeryang Baek 345f3e3559 refac 2026-02-25 15:15:59 -06:00