Commit graph

7047 commits

Author SHA1 Message Date
Classic298
e30ed01b05
perf: stream pure passthrough proxy responses by network chunk instead of by line (#27384)
stream_wrapper without a content handler iterates aiohttp's response.content, which reads line by line: every line costs a buffer scan, a slice, a bytes concat, a generator resume and its own ASGI response message. A typical SSE event is two lines (the data line and the blank separator), so every upstream token event became two yields and two transport writes even on routes where the body is never inspected.

stream_wrapper now takes passthrough=True, which iterates response.content.iter_any(): the exact same bytes, one yield per network read, no line scanning. It is applied only to routes no internal consumer parses line-by-line: the ollama pull/push/create/generate proxies and its v1 completions, chat completions, messages and responses endpoints, plus the openai embeddings, responses and catch-all proxies. The two internally consumed chat routes keep line iteration, which the streaming middleware and the Ollama-to-OpenAI converter require; the ollama send_request signature documents that constraint.

Benchmark (local aiohttp SSE server, 500 events, consumed through stream_wrapper):

| metric | before (readline) | after (iter_any) |
| --- | --- | --- |
| stream consumption time | 1.46 ms | 0.62 ms |
| generator yields + response writes per stream | 1000 | 1 |

The single yield is a loopback artifact (the whole body arrives in one buffered read); over a real network it becomes one yield per TCP read instead of two per SSE event.

Functionally verified: line mode and passthrough mode produce byte-identical output for the same stream, and passthrough always yields fewer, larger chunks.
2026-07-27 00:58:24 -04:00
Classic298
5dcca59aee
fix: route OAuth profile-picture fetch through the SSRF-safe session (#26699)
_process_picture_url validated the picture URL with validate_url() but then fetched it with a plain aiohttp session that resolves the hostname again at connect time, leaving a DNS-rebinding TOCTOU window (the same gap already closed for the RAG loader, the content probe, the image fetches and webhook delivery). Routing the fetch through get_ssrf_safe_session() pins the connect-time resolution via _SSRFSafeResolver and rejects non-global addresses, so a rebinding host can no longer redirect the fetch to loopback, RFC1918 or cloud-metadata endpoints. It also stops the forwarded OAuth access_token from leaking to a rebound internal target.
2026-07-27 00:56:24 -04:00
Timothy Jaeryang Baek
dd86b984bd refac 2026-07-27 00:55:16 -04:00
Timothy Jaeryang Baek
1717b493d8 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-07-27 00:54:28 -04:00
Classic298
41573d52f1
fix: require an authenticated user on the Ollama version route (#27199)
get_ollama_versions was the only Ollama route besides the static health check without an authentication dependency, so an anonymous caller could read the configured backend's version string and, by walking url_idx until the lookup raised, count the configured backends.

Nothing depends on the route being public. The frontend wrapper takes a token and sends it on every call, and its three call sites (admin model management, the model selector and the About panel) all pass an authenticated token, so the client already treats this as an authenticated route. Add the same get_verified_user dependency the sibling routes carry.

Co-authored-by: Grg0rry <Grg0rry@users.noreply.github.com>
2026-07-27 00:44:31 -04:00
Timothy Jaeryang Baek
55e0801dab refac 2026-07-27 00:34:25 -04:00
EntropyYue
f21d7947f9
fix: Set default Redis socket timeout to None (#27104) 2026-07-27 00:30:00 -04:00
Timothy Jaeryang Baek
57e60423b9 refac 2026-07-27 00:27:38 -04:00
Timothy Jaeryang Baek
20647bd2d5 chore: format 2026-07-27 00:12:47 -04:00
Timothy Jaeryang Baek
c727643e05 refac 2026-07-27 00:11:59 -04:00
Timothy Jaeryang Baek
4a7d4ebada refac 2026-07-27 00:10:36 -04:00
Timothy Jaeryang Baek
8ddf119570 refac 2026-07-26 23:55:37 -04:00
Timothy Jaeryang Baek
e5a08d5220 refac 2026-07-26 23:54:16 -04:00
Timothy Jaeryang Baek
6f93ecd4fd refac 2026-07-26 23:49:03 -04:00
Classic298
2196b4e1ff
perf: stop resolving DNS on the thread pool (add aiodns) (#27440)
aiohttp resolves every hostname with ThreadedResolver unless the aiodns package is importable, and ThreadedResolver runs socket.getaddrinfo on asyncio's default ThreadPoolExecutor. That executor is capped at min(32, cpu_count + 4) threads and is shared with every other piece of blocking work posted to it, so DNS is currently a bounded blocking resource sitting in front of every model call, every web search fetch, every RAG page load and every tool call. In plain terms: once that pool is busy, requests wait on name lookups that should never have occupied a thread at all.

This is a dependency-only change. aiohttp sets `DefaultResolver = AsyncResolver` as soon as aiodns is importable (aiohttp/resolver.py), so resolution moves onto the event loop via c-ares with zero application code touched. That is deliberate rather than lazy: there are 50 `aiohttp.ClientSession(...)` construction sites in the backend, most building a fresh default connector per call, and the alternative of passing `resolver=aiohttp.AsyncResolver()` explicitly would mean touching all of them and re-touching every future one. The shared pool in `utils/session_pool.py` does set `ttl_dns_cache`, but that only helps the shared pool. Every per-request session, including `SafeWebBaseLoader._fetch()` which builds a new session per URL, starts with a cold DNS cache and resolves from scratch.

It also unbreaks a code path that is dead today. `backend/open_webui/retrieval/loaders/mistral.py:480` constructs `aiohttp.AsyncResolver()` unconditionally, and `AsyncResolver.__init__` raises `RuntimeError("Resolver requires aiodns library")` when aiodns is absent, so the Mistral OCR content extraction engine fails on a stock install. This supplies the dependency that line already assumes. Once it is present that kwarg is redundant, since it now names the default, and dropping it is a reasonable follow-up. Reproduced by blocking the aiodns import:

```
aiodns importable: False
DefaultResolver: ThreadedResolver
AsyncResolver(): RuntimeError: Resolver requires aiodns library
```

## Benchmarks

Both sides run the real aiohttp resolver classes. The DNS wire time is replaced by an identical fixed 50ms delay on both sides, so the only variable measured is where that delay is spent. 24 cores, so the default executor holds 28 threads. `exec_max` is the worst latency an unrelated `run_in_executor` job suffered while the lookups were in flight.

Concurrent lookups, wall time:

| concurrent lookups | ThreadedResolver | AsyncResolver | speedup | exec_max before | exec_max after |
|---|---|---|---|---|---|
| 16 | 54.3ms | 41.0ms | 1.3x | 2.1ms | 1.9ms |
| 32 | 101.9ms | 50.6ms | 2.0x | 36.6ms | 1.8ms |
| 64 | 152.5ms | 43.2ms | 3.5x | 88.4ms | 2.0ms |
| 128 | 254.9ms | 44.5ms | 5.7x | 190.3ms | 2.2ms |
| 256 | 508.2ms | 50.7ms | 10.0x | 443.8ms | 2.4ms |
| 512 | 965.2ms | 47.8ms | 20.2x | 900.2ms | 2.6ms |

ThreadedResolver scales linearly with concurrency because it can only run 28 lookups at a time. AsyncResolver stays flat at roughly the cost of one lookup.

The reverse direction is worse and is not hypothetical. Open WebUI already posts long blocking jobs to that same executor (`retrieval/vector/dbs/pinecone.py:323` batch upserts, `retrieval/loaders/youtube.py:156` transcript loads). With 28 such jobs holding the pool, a single DNS lookup waits for them to finish:

| | one DNS lookup |
|---|---|
| ThreadedResolver | 1989.7ms |
| AsyncResolver | 58.9ms |

A Pinecone bulk upsert currently stalls name resolution for every other user on the instance. After this change it cannot.

At low concurrency on a real network the two are equivalent, as expected: 8 concurrent lookups against disjoint cold hostname sets landed within noise of each other in both directions.

## Behaviour verification

Checked against Open WebUI's own code, not in isolation:

- c-ares reads the system hosts file. Verified against a machine whose hosts file maps `adobe.io` to `0.0.0.0`, an address real DNS never returns for that name: c-ares returned `0.0.0.0`. `host.docker.internal`, compose `extra_hosts` and Kubernetes `hostAliases` keep working.
- `_SSRFSafeResolver` subclasses `aiohttp.resolver.DefaultResolver`, so this change swaps its base class from ThreadedResolver to AsyncResolver at runtime. It still resolves public hosts, still returns entries with the `host`/`port` keys the SSRF check reads, and still raises on a private address: resolving `localhost` raised `ValueError: The URL you provided is invalid.`
- A real fetch through `get_ssrf_safe_session()` returned 200.
- NXDOMAIN still surfaces as `aiohttp.ClientError` (`ClientConnectorDNSError`), not a c-ares specific exception, so existing error handling is unaffected.

Known limit: c-ares reads `/etc/resolv.conf` and the hosts file but not the rest of `nsswitch.conf`. Names served only by an NSS module, such as `.local` via avahi/mDNS, NIS/LDAP backends or Windows NBNS, will resolve differently or not at all. On a multi-homed test machine the local hostname returned two addresses through the system resolver and one through c-ares. Deployments pointing Open WebUI at an mDNS or NetBIOS hostname are the group affected. Resolver failures also arrive as plain `OSError` rather than `socket.gaierror`, which no code in this repo catches today.
2026-07-26 23:29:52 -04:00
Classic298
f32b19c1f6
feat: add {{USER_GROUPS}} and {{USER_GROUP_IDS}} placeholders for custom forwarded headers (#27236)
Custom per-connection headers can now forward the user's groups to
upstream backends via two new template placeholders:

- {{USER_GROUPS}}: comma-separated group names
- {{USER_GROUP_IDS}}: comma-separated group ids

The group lookup is async, so get_custom_headers becomes an async
wrapper around the sync template substitution (parse_custom_headers)
and fetches groups lazily — only when a header value actually
references a groups placeholder. The external document loader path
runs in a worker thread without an event loop, so Loader.aload
prefetches the groups before offloading and passes them through to
ExternalDocumentLoader.


Claude-Session: https://claude.ai/code/session_01EbBEfTyu8fFJmC13rnQthT

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:21:26 -04:00
Timothy Jaeryang Baek
3cd72ee6a8 refac 2026-07-26 23:19:20 -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
95d590b360 refac 2026-07-26 23:03:32 -04:00
Timothy Jaeryang Baek
8b206de48e refac 2026-07-26 22:59:23 -04:00
Timothy Jaeryang Baek
bf35f64a7f refac 2026-07-26 22:45:11 -04:00
Timothy Jaeryang Baek
fc4906c9e9 refac 2026-07-26 22:32:17 -04:00
Timothy Jaeryang Baek
aadab2f480 refac 2026-07-26 22:32:06 -04:00
Timothy Jaeryang Baek
846ba80a9d refac 2026-07-26 22:32:03 -04:00
Timothy Jaeryang Baek
d14fddf254 refac 2026-07-26 21:55:13 -04:00
Timothy Jaeryang Baek
0cbf337679 refac 2026-07-26 21:54:06 -04:00
Timothy Jaeryang Baek
85c47fb467 refac 2026-07-26 21:51:35 -04:00
Timothy Jaeryang Baek
71c4da8c06 refac 2026-07-26 21:12:14 -04:00
Timothy Jaeryang Baek
b45c020f68 refac 2026-07-26 21:08:44 -04:00
Timothy Jaeryang Baek
d2936c880c refac 2026-07-26 21:07:27 -04:00
Timothy Jaeryang Baek
d484a2a99e refac 2026-07-26 21:07:20 -04:00
Timothy Jaeryang Baek
f798d05586 refac
Some checks are pending
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
2026-07-26 19:34:41 -04:00
Timothy Jaeryang Baek
94a60b0457 refac 2026-07-26 19:10:41 -04:00
Classic298
54f06d8c53
perf: build chat responses without deep-copying the blob through model_dump (#27388)
Chat search built each result row with ChatTitleIdResponse(**chat.model_dump(), ...), which recursively copies the entire chat blob per row only for the constructor to ignore everything except id, title and timestamps: a 60-row search page deep-copied up to 60 full conversations. The folder listing, archived and export endpoints and every single-chat response did the same dump-and-revalidate dance via ChatResponse(**chat.model_dump()).

Search rows are now built from the five fields the response actually has (the snippet helper receives the blob by reference as before), and all 18 ChatResponse constructions use ChatResponse.model_validate(chat, from_attributes=True), which reads the fields off the already-validated ChatModel without copying the blob.

Benchmark (~500 KB chat blob):

| metric | before | after |
| --- | --- | --- |
| search result row | 0.05 ms | 0.003 ms |
| ChatResponse construction | 0.05 ms | 0.003 ms |
| per search page (60 rows) | 3 ms | 0.2 ms |

Beyond CPU, each converted row also stops materializing a second full copy of the conversation in memory while the page is being built.

Functionally verified: both construction styles produce identical model_dump() output for ChatResponse (including defaulted fields absent on ChatModel) and for search rows including the snippet.
2026-07-26 18:57:52 -04:00
Classic298
4f93c3e36c
fix: authorize before cancelling tasks in the chat delete endpoint (#27006)
DELETE /api/v1/chats/{id} called stop_item_tasks(id) before checking the
caller's chat.delete permission or ownership of the target chat. An
authenticated user who knew another user's chat id could therefore cancel that
chat's in-flight generation (streaming response, title or tag generation) even
though the deletion was then rejected. The chat id is discoverable through
legitimate read-only access to a shared chat or folder.

Reorder the handler to authorize first (admin, or owner holding chat.delete) and
only then cancel tasks and delete, matching the dedicated task-stop endpoint.
Legitimate deletions are unchanged; an unauthorized caller now returns 404 or 401
before any cancellation. The duplicated tag-cleanup and event-publish blocks are
merged.

Co-authored-by: GabrielGomesAL <193945687+GabrielGomesAL@users.noreply.github.com>
2026-07-26 18:57:35 -04:00
Classic298
c055203f29
fix: refresh OAuth session before the id_token expires (#27520)
`_normalize_token_expiry()` derived the session expiry from the access token alone, and that value is what `oauth_session.expires_at` stores and what both `get_oauth_token()` implementations check to decide whether to refresh five minutes ahead. Providers that issue a shorter-lived id_token than access token (Microsoft Entra ID: roughly 60 minutes against 75) therefore left a window where the session still looked valid while the id_token had already expired, so pipes and tools reading `__oauth_token__["id_token"]` forwarded a dead JWT and downstream services rejected it with 401.

The stored expiry is now capped at the id_token's `exp` claim whenever that JWT expires first, which moves the refresh ahead of the earliest expiring token in the set. This is applied in the single function every session write already passes through, so it covers both the SSO manager and the MCP client manager on their callback and refresh paths alike. Sessions without an id_token, with an opaque one, or with no `exp` claim are unaffected.

Fixes #27066
2026-07-26 18:56:18 -04:00
Classic298
99da2324e3
fix: preserve chunk order when assembling multi-chunk transcriptions (#27417)
When an audio file is split into multiple chunks for transcription, transcribe() collected the per-chunk results with asyncio.as_completed(), which yields results in completion order rather than submission order. Whenever a later chunk finished transcribing before an earlier one, the assembled transcript was scrambled, for example the second half of a recording appearing before the first, and the stored file content plus everything downstream (file preview, full-context retrieval) read out of chronological order. This change awaits the chunk tasks with asyncio.gather() instead, which runs them just as concurrently but returns the results in the order the tasks were created, i.e. chunk_paths order. The existing error handling and chunk cleanup are unchanged: an HTTPException from a chunk is re-raised as is and any other error is wrapped in a 500. Fixes #27143
2026-07-26 18:55:52 -04:00
Classic298
381149ea5e
fix: persist filter outlet() changes to structured message output (#27414)
When a filter's outlet() modified the structured assistant output in place, the change was shown immediately but lost after reload. outlet_filter_handler built its outlet payload with a shallow reference to the message's output list from messages_map, so the filter mutated the stored baseline itself and the subsequent output comparison compared the object against itself, never detecting a change and never persisting it. The same aliasing corrupted originalContent for messages whose text lives only in output. Deepcopy the output when building the outlet payload so messages_map stays a pristine pre-filter baseline and the existing change detection persists outlet-modified output through the existing upsert path. Fixes #27017.
2026-07-26 18:55:21 -04:00
Timothy Jaeryang Baek
bef63a2ae9 refac 2026-07-26 18:54:17 -04:00
Timothy Jaeryang Baek
df94268e89 refac 2026-07-26 18:54:07 -04:00
crustopher-lgtm
5efe0951d5
feat: add OpenSERP self-hosted web search backend (#27437)
Add self-hosted OpenSERP as a web search engine option. OpenSERP
provides browser-rendered search across Google, Bing, Yandex, Baidu,
DuckDuckGo, and Ecosia with no API keys required.

- New module: retrieval/web/openserp.py (async, uses aiohttp session pool)
- Config: OPENSERP_BASE_URL env var (defaults to http://localhost:7070)
- Routing: search_web() dispatch for 'openserp' engine
- Follows existing patterns (searxng, brave)

Co-authored-by: crustopher-lgtm <crustopher-lgtm@users.noreply.github.com>
2026-07-26 18:52:08 -04:00
Timothy Jaeryang Baek
42ea8a5a2f refac 2026-07-26 18:50:22 -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
db92ef292f
fix: unarchive chats moved into folders and refresh sidebar folders after menu moves (#27485) 2026-07-26 18:43:34 -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
Timothy Jaeryang Baek
453b9fb029 refac 2026-07-26 18:36:49 -04:00
Classic298
50afbc5319
fix: allow setting model order via MODEL_ORDER_LIST env var (#27420)
With ENABLE_PERSISTENT_CONFIG=False the admin's model order is reset on every restart because ui.model_order_list falls back to its DEFAULT_CONFIG default, and unlike every other Models setting (DEFAULT_MODELS, DEFAULT_PINNED_MODELS, DEFAULT_MODEL_METADATA and DEFAULT_MODEL_PARAMS) that default was hardcoded to an empty list with no environment variable to source it from. This adds a MODEL_ORDER_LIST environment variable parsed as a JSON array using the same guarded pattern as the neighbouring DEFAULT_MODEL_METADATA and DEFAULT_MODEL_PARAMS defaults, falling back to an empty list on parse errors. Behaviour when the variable is unset is unchanged.

Fixes #27206
2026-07-26 18:34:18 -04:00
Classic298
0116c6e1b9
perf: stop running chardet over entire uploaded files (#27445)
`_detect_text_encoding()` hands the complete file to `chardet.detect()`. chardet is pure Python and costs roughly 1.3 seconds per megabyte, so uploading a large non-UTF-8 text file stalls for seconds inside encoding detection alone. A 4 MiB Shift-JIS file spends 6.4 seconds there. The UTF-8 fast path above it means only non-UTF-8 files reach this, which in practice are exactly the CJK documents the surrounding code was written to handle, so the slow case and the case that matters are the same case.

Detection does not need the whole file. It needs the bytes that are actually not UTF-8, and `UnicodeDecodeError.start` from the fast-path decode already says where those begin, so this samples a 256 KiB window around that offset.

Two things make that safe rather than merely fast.

Centring the window on the first non-UTF-8 byte instead of the file head is what keeps the common case correct. A plain head sample makes chardet report ascii for a file that is ASCII for its first few hundred KiB and only turns CJK later, and the method then falls through to latin-1 instead of the right codec.

The window still cannot help when a stray byte, a pasted Windows-1252 artifact for example, sits hundreds of KiB ahead of the real payload: the sample is then almost pure ASCII and carries no signal. So when the sample holds almost no non-ASCII bytes and is a strict subset of the file, detection falls back to the whole buffer. That case pays the old cost, which is the right trade, because it is precisely the case where sampling would otherwise be wrong. Without this guard a Cyrillic document with a stray leading byte was detected as ISO-8859-1 rather than windows-1251, which is silent mojibake.

Measured, with the encoding returned identical in every case:

| file | before | after |
|---|---|---|
| shift_jis 4 MiB | 6402ms | 755ms |
| gb18030 4 MiB | 3199ms | 449ms |
| big5 4 MiB | 2926ms | 413ms |
| euc-jp 4 MiB | 2456ms | 413ms |
| euc-kr 4 MiB | 2382ms | 468ms |
| latin-1 4 MiB | 1902ms | 394ms |
| gb18030 1 MiB | 807ms | 376ms |
| ascii head then gb18030 tail | 533ms | 294ms |
| stray byte then cp1251 payload | 496ms | 1051ms |
| any UTF-8 file | 8ms | 0ms |

29 cases, all returning an identical encoding before and after: six encodings at 100 KiB, 1 MiB and 4 MiB, three layouts where the non-UTF-8 bytes only begin beyond the window, four where a stray byte is separated from the payload, plus plain UTF-8, UTF-8 CJK and an empty file. The stray-byte rows are slower than before because they scan twice, once over the window and once over the whole buffer. They are the pathological shape, and correctness wins there.

The residual time is now the decode-and-validate loop below, which walks the file once per candidate codec, and `_has_cjk_characters`, which is a per-character Python loop over the decoded text. Both are the same "full scan for a detection decision" pattern and could take a bounded prefix too. That is left alone here.
2026-07-26 18:34:02 -04:00