The `response.completed` handler replaced the accumulated output with the terminal event's `output` whenever that key was present, guarded only by `is not None`. An empty array satisfies that guard, so a provider that finishes the stream with `"output": []` wiped everything collected from `response.output_item.added`, `response.output_text.delta` and `response.output_item.done`.
The assistant message was then persisted with `output: []` and empty content, which shows up as a reply that renders correctly while streaming and disappears the moment the stream ends.
Fall back to the accumulated output when the terminal array is empty. A spec-compliant `response.completed` still wins, since a populated array is truthy, and when nothing was streamed the accumulated output is empty too, so the fallback cannot invent content.
Fixes#27789
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.
`request.app.state.MODELS` is a `RedisDict` when Redis is configured. Unpacking it with `{**pool}` makes Python call `keys()` and then `__getitem__` once per key, which is one HKEYS plus one HGET per model, issued sequentially through a synchronous client. At 200 models that is 201 blocking Redis round trips per call.
`RedisDict.items()` is a single HGETALL, so `dict(pool.items())` fetches the same data in one round trip. `utils/chat.py:184` already does exactly this and carries a comment explaining why; these ten call sites were missed.
They are on the direct-connection branch of the task endpoints (title, tags, follow-up, autocomplete, query generation and the rest), of `chat_completed`, and of context compaction, so they run for background tasks fired on ordinary chat turns.
Behaviour is unchanged. The merged mapping is identical, the explicitly added direct model still overrides any pool entry with the same id, and when Redis is not configured the pool is a plain dict where `dict(d.items())` and `{**d}` are equivalent.
It also closes a race. `RedisDict.set` writes with HSET and then HDELs the stale keys, so a key returned by HKEYS could be deleted before its HGET arrived, raising `KeyError` out of the dict literal and failing the request mid model refresh. The old path could likewise observe a mix of pre- and post-refresh entries. HGETALL is atomic, so the caller now always sees one coherent snapshot.
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
* fix: preserve system prompt across tool calls when memories are enabled
The native tool-call loop runs generate_chat_completion with
bypass_system_prompt=True, so the provider layer does not re-apply the
model's default system prompt on tool-call iterations. It relies instead
on metadata['system_prompt'], captured in process_chat_payload, to carry
the full system prompt forward and restore it after RAG injection.
That capture read the model default system prompt from
form_data['params']['system'], but apply_params_to_form_data had already
popped 'params' from form_data, so model_system_prompt was always empty.
metadata['system_prompt'] therefore only captured whatever was already
materialized in the messages. With memories enabled, that is the injected
<memory_context> system message, so tool-call requests were restored with
memory-only system content and the model's system prompt was dropped.
Without memories there was no system message to capture at all.
Capture the model default system prompt from form_data['params'] before
apply_params_to_form_data pops it, and use that value when building
metadata['system_prompt'].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Z4L51mvxDJCx1EDxP2vFN
* refactor: condense system prompt capture comment to a single line
Replace the four-line explanation above the model_system_prompt capture with a one-line note. The variable name and the surrounding code already convey what happens; the comment only needs to state why the capture sits before apply_params_to_form_data.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Previously, when web search retrieved pages successfully but saving them to the vector DB failed (for example an unreachable or misconfigured embedding endpoint), process_web_search swallowed the exception at debug log level and still returned status: True with the collection name. The chat then showed "Searched N sites" followed by "No sources found" at retrieval time, hiding the actual misconfiguration from the user and making the failure look like a search bug.
process_web_search now logs the failure at exception level and raises an HTTPException with an actionable message pointing at the embedding configuration in Admin Settings > Documents. chat_web_search_handler surfaces the detail of any HTTPException raised during the search in the emitted error status, so the real cause (embedding misconfiguration, search engine errors, no results) is shown in the chat UI instead of the generic "An error occurred while searching the web". Non-HTTP exceptions keep the generic message, so raw internal error strings are not exposed.
Ref #26750, #25038
The legacy features block in process_chat_payload honoured client-supplied features.image_generation and features.web_search flags and dispatched to the image generation/edit provider and the web-search provider without re-checking the per-user permission that the direct /images routes and the native function-calling path enforce. A user denied features.image_generation or features.web_search could still trigger billable server-side image generation or web search via POST /api/chat/completions with params.function_calling set to legacy. Gate both branches on admin-or-has_permission before invoking chat_image_generation_handler / chat_web_search_handler, matching the existing code_interpreter gate, so a forged flag from an unpermitted user is ignored. Normal completions and permitted users are unaffected.
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.
The streaming handler's content accumulator is a closure cell (declared nonlocal in stream_body_handler), and CPython's in-place string append optimization only applies to plain local variables (STORE_FAST), never to cell variables (STORE_DEREF). The content += value form introduced in #27231 therefore still allocates and copies the full accumulated string on every delta, exactly like the f-string it replaced; whether that copy is cheap or expensive is up to the allocator, and measurements swing accordingly (6 to 83 ms of pure copying for a 400 KB response on Python 3.12, against 0.3 ms for this patch).
Accumulate the deltas in a list instead and join once at the single read site (publish_chat_finished_event at stream end). List append is amortized O(1) with no dependence on reference counts, bytecode specialization or allocator behaviour, so accumulation is O(n) by construction. The non-str fallback keeps the previous f-string coercion semantics.
Verified end to end against a mock SSE upstream: a streamed chat with a think-tag block plus 40 content deltas produces output items, message text, reasoning text and usage identical to current dev, with no errors in the server log.
The branch that normalizes plain JSON error lines from streaming upstreams (lines without the SSE data: prefix) called Chats.upsert_message_to_chat_by_id_and_message_id without await. The coroutine was never executed, so the error was never written to the chat and Python emitted a "coroutine was never awaited" RuntimeWarning instead. The frontend still received the error event, but after a reload the message showed no trace of the failure.
The parallel error-persist branch further down the same handler already awaits the call; this aligns the two.
Several spots in the chat pipeline issued sequential single-key config
SELECTs, or fetched the same key twice back-to-back, on every request:
- chat_completion_tools_handler: task model default/external and the
tools prompt template were four sequential Config round trips (the
template was fetched twice). One batched Config.get_many now serves
all of them.
- chat_completion_files_handler: the six RAG settings (top_k,
top_k_reranker, relevance_threshold, hybrid_bm25_weight,
enable_hybrid_search, full_context) were six sequential round trips
inside the retrieval call. Batched into one get_many.
- Voice and code-interpreter prompt templates were each fetched twice
within one conditional; fetch once and reuse. The code-interpreter
engine was likewise fetched twice per execution.
- Skill resolution fetched the accessible-skills list, kept only the
ids, then re-fetched each mentioned skill by id (N+1). Reuse the
rows from the access query.
Value semantics are identical: get_many applies the same defaults as
the individual gets, and the pre-existing truthiness/empty-string
checks on templates are preserved exactly.
Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD
Co-authored-by: Claude <noreply@anthropic.com>
The middleware code-interpreter path defines `restricted_import` as `async def` and assigns it to `builtins.__import__`, which Python's import machinery calls synchronously. Calling an async function returns a coroutine without running its body, so the blocklist check never executes and `_real_import` is never called. When `CODE_INTERPRETER_BLOCKED_MODULES` is set, blocked modules are therefore not blocked, and every subsequent import inside the interpreter binds a dangling coroutine instead of the module, breaking legitimate imports as well.
Define the hook as a regular `def`, matching the working implementation in `tools/builtin.py`. A blocked top-level import now raises `ImportError`, and all other imports pass through to the real importer.
The streaming handler rebuilt the full accumulated response with
`content = f'{content}{value}'` on every content delta — a complete
string copy per chunk, making accumulation O(n^2) over the response
length. Use in-place `content += value` for the (universal) str case,
which CPython extends in place, keeping accumulation O(n); the
f-string fallback is preserved for non-str values so coercion
behavior is unchanged.
In the ENABLE_REALTIME_CHAT_SAVE branch, full_output() — which
concatenates the entire accumulated output — was called twice per
chunk (once for the DB upsert, once for the emitted delta). Compute
it once and reuse.
Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD
Co-authored-by: Claude <noreply@anthropic.com>