1697 Commits
Author SHA1 Message Date
Timothy Jaeryang Baek 5c62cc0517 chore: format 2026-08-25 16:53:53 -04:00
Timothy Jaeryang Baek 067114c280 refac 2026-08-25 16:04:43 -04:00
Classic298andGitHub b1bfc18762 perf: cache the serialized builtin tool spec instead of deep-copying it per request (#28860)
Every chat request hands each builtin tool a fresh copy of its cached spec, because callers mutate what they get. That copy was a full deepcopy of a nested dict, repeated per tool per message.

The builder now caches the spec already serialized, so a request only parses it back. Parsing is what produces the independent tree callers mutate, and the cached value becomes an immutable string, so a request can no longer reach the cached object at all.

Measured on CPython 3.12 with a 1.1 KB spec and 20 builtin tools per request:

| | before | after |
|---|---|---|
| stdlib json, the default | 276.2 us | 66.4 us |
| orjson | 279.5 us | 37.5 us |

Builtin specs are plain JSON by construction: pydantic normalizes every default before it reaches the schema, so a tuple, set, enum or datetime cannot appear in one, and an unserializable default is dropped rather than embedded.
2026-08-25 16:00:34 -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
Classic298andGitHub ac85b0f2a2 refac: gate code interpreter tag detection to legacy tool-calling mode (#29024)
Tag detection for the code interpreter ran regardless of the tool-calling mode, so a model in Native (Agentic) Mode that emitted <code_interpreter> blocks in ordinary reply text had that code sent to the executor. Native mode never teaches the tag format and exposes execute_code as a builtin tool, so the parser had nothing legitimate to pick up there.

Gates detection on the legacy mode, matching the condition that already decides whether the tag prompt is injected at all. The five authorization checks are unchanged, and native mode keeps executing through the tool.

Deployments on native mode whose models emit the tags unprompted will now see them rendered as text.
2026-08-25 15:33:55 -04:00
Timothy Jaeryang Baek 28f2965934 refac 2026-08-25 15:26:45 -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
Timothy Jaeryang Baek 35fbde0a3f refac 2026-08-25 15:00:53 -04:00
Timothy Jaeryang Baek 684111715f refac 2026-08-25 14:56:23 -04:00
Timothy Jaeryang BaekandClassic298 c4b3e6840f refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-08-25 14:07:50 -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
Timothy Jaeryang Baek ca4e07a40b refac 2026-08-25 11:07:54 -04:00
Timothy Jaeryang Baek 97466deea1 refac 2026-08-24 18:38:29 -04:00
Timothy Jaeryang Baek b96d2b12da refac 2026-08-24 18:29:36 -04:00
Timothy Jaeryang Baek 6cb2449ab7 refac 2026-08-24 18:10:08 -04:00
Timothy Jaeryang Baek cf4ac9c8db refac 2026-08-24 18:07:03 -04:00
Timothy Jaeryang Baek fd8cc2ba4a refac 2026-08-24 18:06:59 -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 a6834f089b refac 2026-08-24 17:47:10 -04:00
Timothy Jaeryang Baek 91917b2395 refac 2026-08-24 17:16:01 -04:00
Classic298andGitHub 23b3a69bc2 fix: keep folder parent references acyclic (#28748)
Moving a folder under one of its own subfolders was accepted. A folder in a parent loop is never a root, so it and everything under it silently disappeared from the sidebar, and there was no way to get it back from the UI.

The move is now rejected with a 400, folders whose parent chain loops are put back at the root on the next folder list, and the folder tree traversals skip ids they have already visited so existing data in that state stays workable.
2026-08-24 17:06:19 -04:00
Timothy Jaeryang Baek aeda6ff13a refac 2026-08-24 16:29:57 -04:00
G30andGitHub fd7024f198 fix: log tool server connectivity failures without a traceback (#27757) 2026-08-24 07:35:57 -04:00
Classic298andGitHub 9cf1a07960 fix: use the pooled client timeout for the Anthropic Messages passthrough (#27675)
* fix: use the pooled client timeout for the Anthropic Messages passthrough

The native `/api/v1/messages` passthrough still referenced `openai.AIOHTTP_CLIENT_TIMEOUT`, which stopped existing when `routers/openai.py` moved onto `session_pool.get_client_timeout()`. Every passthrough request therefore raised `AttributeError: module 'open_webui.routers.openai' has no attribute 'AIOHTTP_CLIENT_TIMEOUT'` before it was sent, and the surrounding handler turned that into a 502 "Open WebUI: Server Connection Error", so Anthropic-format clients such as Cline could not reach any model at all.

Use `get_client_timeout(stream=...)` like the OpenAI and Ollama proxies do, so the configured `AIOHTTP_CLIENT_TIMEOUT` applies and streaming requests additionally get the idle-read timeout.

Fixes #27595

* fix: authenticate native Anthropic requests with x-api-key

The Anthropic Messages passthrough and the token-count forwarding both build their upstream request through `get_anthropic_request_target`, which sends the connection key as `Authorization: Bearer <key>`. Anthropic's OpenAI-compatible `/chat/completions` endpoint accepts that, which is why the model works in the chat UI, but the native `/v1/messages` and `/v1/messages/count_tokens` endpoints do not: they require the key in `x-api-key` and reject a bearer token with 401 `Invalid bearer token` (and `jwt auth is not yet supported on count_tokens`). They also require an `anthropic-version` header, which was never sent.

For `api.anthropic.com` connections, send `anthropic-version` and move the key into `x-api-key`, dropping the bearer header. Connections using session, OAuth or Entra ID auth keep their token untouched, LiteLLM passthrough connections are unaffected, and admin-configured custom headers still win over both defaults.

Fixes #27695
2026-08-24 07:33:33 -04:00
Classic298andGitHub 091c44c621 perf: stop rescanning the whole response for tag boundaries on every streamed chunk (#28861)
Streamed responses are scanned for reasoning and code interpreter tags. To work out where the last complete tag ended, the scanner searched backwards from the start of the accumulated text on every chunk, once per tag set. Ordinary prose contains no angle bracket, so that search never stopped early and read the entire response back every time. The cost grows with the square of the response length, and this scanning is on unless a model turns it off.

The two positions are now carried forward as the text grows, so each chunk only scans the characters it added.

Measured on CPython 3.12, a 270 KB response streamed in 27000 chunks:

| response text | before | after |
|---|---|---|
| no newlines | 7690 ms | 40.6 ms |
| with newlines | 5695 ms | 41.7 ms |

The carried positions match a full rescan at every step of 36282 randomized replays, covering text with no markers, newlines only, dense markers, real tags and truncation part way through.
2026-08-24 05:11:32 -05:00
Classic298andGitHub 16c2a9eda4 fix: index the chat queries that make large SQLite instances unusable (#27663)
The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.

Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.

Measured on SQLite, same rows returned:

| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |

PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.

Fixes #27622
2026-08-23 16:11:54 -05:00
Classic298andGitHub ac091273b7 fix: keep streamed text when a filter or provider sends non-string content (#28840)
A stream filter function, or a provider that puts something other than a string in a delta, makes the streaming handler concatenate a string with a non-string. That raises TypeError, and the broad handler wrapped around the whole per-chunk block swallows it at debug level and moves on. The chunk's text never reaches the message the user sees, and nothing above debug level says why.

The content and reasoning fields are now coerced to text once, where they are read off the delta, ahead of every consumer. The coercion is guarded on truthiness, so falsy values such as an empty list still skip the block exactly as before, and the accumulated content receives byte for byte what it received previously.

Checked against 14 delta shapes covering strings, empty values, numbers, booleans, None, lists, dicts and a content array: the truthiness gate and the accumulated content are identical before and after.
2026-08-23 15:35:53 -04:00
Timothy Jaeryang Baek f64c0c87e8 refac 2026-08-23 15:14:43 -04:00
Timothy Jaeryang Baek 78f48a21ee refac 2026-08-23 14:40:48 -04:00
Timothy Jaeryang Baek 5093a99389 refac 2026-08-23 13:36:53 -04:00
Timothy Jaeryang Baek f3f76095d1 refac 2026-08-23 02:34:08 -04:00
Timothy Jaeryang Baek d17f06a235 refac 2026-08-22 08:43:46 -04:00
G30andGitHub 883c7434fb fix: tolerate reasoning items without started_at when closing them at stream end (#28872) 2026-08-21 15:51:21 -07:00
Classic298andGitHub b0fdc00452 perf: write task payloads to Redis as bytes (#28833)
Saving a streaming response serialized the payload with orjson, decoded it to
str, scanned it for the three Unicode line separators and let redis-py encode
it straight back to UTF-8: on an 8 MB non-ASCII chat that is 6.9 ms and ~22 MB
of transient buffers per write, synchronously on the event loop.

json_codec now exposes dumps_bytes, which returns the serialized payload as
UTF-8 bytes without the line-separator escaping, and the two Redis writes in
tasks.py use it. That escaping only protects line-framed protocols such as
SSE; every reader of these Redis values re-parses them before anything is
served, and the escaped and raw forms parse identically, so mixed versions
during a rolling deploy interoperate both ways. The same write drops to
0.9 ms and one 8 MB buffer (7.5x), with 31-66% saved on KB-sized writes.
With ENABLE_ORJSON off, dumps_bytes wraps stdlib json, behaviour unchanged.

The str path keeps the escaping but applies it with chained str.replace
instead of a translate table, cutting a separator-containing 8 MB payload
from 312 ms to 5.7 ms with byte-identical output.
2026-08-20 12:58:52 -07:00
Timothy Jaeryang Baek 8a42aa53e8 refac 2026-08-19 22:48:32 -07:00
Classic298andGitHub 7cf6051a74 perf: resolve group membership once per folder listing instead of once per entry (#28810)
Listing a user's folders re-checks which entries they may still see, and it resolved their group membership again for every folder, then again inside the collection and note branches for every entry. A comment in that helper claims one membership fetch for the whole listing, but the caller invokes it once per folder, so the claim never held.

The listing now resolves membership once, and only when some folder actually carries entries, then threads it through the file, collection and note checks. Callers that do not supply it are unchanged and still resolve for themselves.

Measured with twenty folders holding six files, two knowledge bases and two notes each: 245 queries and ~145 ms before, 186 and ~117 ms after. The folders returned, and the entries the integrity pass writes back, are unchanged. That was checked against entries the caller owns, entries shared through a group, entries shared with nobody, another user's files, and an unrecognised entry type.
2026-08-19 11:16:29 -07:00
2e7df54673 fix: surface attached chat references in <attached_files> (#28788)
A chat attached via the "+" menu or dropped from the sidebar references an
existing chat by id and carries no url. add_file_context() filtered on
`file.get('url')`, so the reference was dropped from <attached_files>
entirely and the model was never told it existed.

When the RAG file-context path is enabled the chat content still reaches
the model as <source> context, which masked this. With file_context
disabled that path is skipped, and get_attached_knowledge() only promotes
collection/note items into <attached_knowledge> - so an attached chat was
visible in the UI but invisible to the model, which then reported having
no chat attachments despite having a view_chat tool available.

Keep chat references and emit their id so the model can resolve them with
view_chat. The url attribute is now conditional, since a chat has none;
the id guard it replaces was dead once the filter guarantees a url or a
chat id.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 11:15:22 -07:00
Classic298andGitHub 4f98a5184f perf: resolve model-attached file access with a targeted query (#28802)
Checking whether a user may reach a file loaded and validated every workspace model that user can access, then scanned each model's knowledge list in Python for one file id. Folder listings run that check once per file, so opening a folder of twenty files rebuilt the whole accessible-model set twenty times, and the same check sits on every retrieval and download path.

The lookup now runs the other way round: the database returns the models that attach the file, and only those are access-checked. The text match on the metadata column is a prefilter and the knowledge entries still decide, so a file id that merely appears in a description grants nothing; file ids are server-generated uuids, so the match can only be too wide, never too narrow.

Measured with 500 accessible workspace models: a single check drops from 9 queries and ~20 ms to 6 and ~2.6 ms, and a twenty-file folder listing from 180 queries and ~680 ms to 120 and ~56 ms. A 72-case matrix over owner, public, direct-user and group grants, for both read and write, returns exactly what it returned before, and write still requires the model owner to own the file. The check also no longer writes to the database while answering a read-only question.
2026-08-19 11:07:43 -07:00
Classic298andGitHub dbf715cb63 perf: stop scanning every skill on each listing and chat turn (#28798)
Listing skills ran one database query per skill in the instance. A non-admin opening the list on a workspace with 500 skills issued over 500 queries, the paginated list re-resolved the caller's group membership once per row, and every chat message carrying a skill loaded every skill the user can read, full body and owner included, to use the two or three it actually referenced.

Skills now arrive already filtered: the owner-or-grant check runs in the query as an EXISTS subquery, the same way prompts and the search endpoints already do it, the per-item write flag uses the existing batch grant lookup, and the chat path asks only for the skill ids the request names.

Measured with 500 skills of which 3 are visible to the caller: 504 queries and ~300 ms before, 4 queries and ~2.6 ms after. The resulting set is unchanged for owner, public, direct-user, group and multi-grant entries, for both read and write.
2026-08-19 11:07:33 -07:00
Classic298andGitHub 284da2ae49 perf: reuse the already loaded chat when assembling builtin tools (#28809)
Assembling the builtin tools for a chat message fetched the chat row a second time to answer one question: whether this is a note chat. The caller had loaded that same row a few lines earlier, from the same id in the same metadata dict, and had already evaluated the same predicate for its own note handling. So every message with builtin tools enabled read the whole conversation blob twice.

The caller now works the flag out once and passes it down. Tool assembly no longer touches a chat model at all, so the two files cannot drift apart when the shape of that metadata changes.

Measured with a stub request across five chat shapes, a note chat, a plain chat, an internal chat that is not a note, a chat id with no row behind it, and an unsaved chat id: the returned tool set is identical in every case and the query count drops from six to five. The note tools are still enabled for a note chat with the notes feature switched off, which is the only thing that predicate decides.
2026-08-19 11:06:49 -07:00
Classic298andGitHub 21e390561d fix: revoke existing sessions when a password changes (#28725)
Changing a password left every other logged-in device working until the JWT expired on its own, up to four weeks with the default settings. The hardening docs already promise the opposite: with Redis configured a password change is supposed to put the user's tokens on the revocation list, but only sign-out and OIDC back-channel logout ever wrote to it.

Both password-change paths, self-service and an admin resetting someone's password, now stamp the per-user revocation marker that token validation already checks, so every session issued before the change stops working. The acting device is signed out as well and asked to sign in again, which is the safer default when the password is being changed precisely because the old one may be compromised. Without Redis nothing can be revoked, as before, and the backend now logs a warning saying so.

The marker is written through one shared helper, so its lifetime follows the configured JWT lifetime instead of a fixed 30 days and never expires at all when JWT_EXPIRES_IN disables expiry. Back-channel logout picks that up too, where a long or disabled JWT lifetime previously let the marker expire while the tokens it revoked were still valid. API keys keep working, they are separate credentials with their own lifecycle.

Discussed in #28647.
2026-08-17 13:56:29 -07:00
G30andGitHub 88c55b86b1 feat: emit auth.login on SSO logins and attribute SSO logouts (#27619)
* feat: emit the auth.login event on SSO logins

* feat: attribute SSO logouts in the auth.logout event payload
2026-08-17 02:22:25 -06:00
Classic298andGitHub 646a568ae6 fix: enforce global web search and image generation switches on the legacy function-calling path (#27669)
The legacy function-calling path acted on the client-supplied `features` dict after checking only the per-user permission, so a user who still held `features.web_search` or `features.image_generation` could keep triggering web searches and image generation after an administrator had switched those off instance-wide. The native function-calling path already gates the equivalent builtin tools on `web.search.enable` and `image_generation.enable` in `get_builtin_tools`, so the two paths disagreed and the admin-level switch did not actually stop the outbound provider calls it was turned off to stop.

Gate the legacy web search handler on `web.search.enable` at its call site, and gate `chat_image_generation_handler` on the two image switches internally. The image handler needs the check inside it because `image_generation.enable` and `images.edit.enable` are independent: editing stays available when generation is disabled, matching the `/images/generations` and `/images/edit` routes and the native `generate_image`/`edit_image` tools. The handler calls `image_generations`/`image_edits` directly and so bypasses the route guards, which is why the check has to live at the caller.

The "Creating image" status event moves below the new guard so a disabled configuration returns without leaving an unresolved progress indicator in the chat.
2026-08-17 02:15:05 -06:00
Timothy Jaeryang Baek 0b27fa5e87 refac 2026-08-17 00:57:57 -07:00
Timothy Jaeryang Baek e968445812 refac 2026-08-17 00:43:47 -07:00
Timothy Jaeryang Baek d799e81edb refac 2026-08-17 00:42:16 -07:00
Timothy Jaeryang Baek f1a64ccfc2 refac 2026-08-17 00:35:12 -07: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
G30andGitHub f5a5a434b9 fix: record an error state when a timer's chat completion raises (#27785) 2026-08-17 01:22:47 -06:00