Files

85 lines
3.7 KiB
Python
Raw Permalink Normal View History

"""The app-wide JSON codec, selected by the ``ENABLE_ORJSON`` env var.
Every module that would otherwise reach for stdlib ``json`` imports ``JSONCodec``
from here, so the whole app switches implementation from a single flag. With the
flag off these are stdlib ``json`` and engineio's codec verbatim, so the default
behaviour is exactly what it was before orjson entered the picture. ``dumps_bytes``
returns UTF-8 bytes for sinks that re-parse the payload; under orjson it skips
both the str round trip and the line-separator escaping ``dumps`` applies, so
never feed it to line-framed output such as SSE.
"""
from __future__ import annotations
import json as stdlib_json
from engineio import json as engineio_json
from open_webui.env import ENABLE_ORJSON
if ENABLE_ORJSON:
import orjson
# Module-level because CPython rebuilds these dicts on every call.
FAST_PATH_KWARGS = ({'separators': (',', ':')}, {'ensure_ascii': False})
class ORJSONCodec:
"""stdlib-``json``-compatible codec backed by orjson.
The fast path is not byte-for-byte stdlib: it is always compact, formats
floats orjson's way (``1e16``, not ``1e+16``), and is raw UTF-8 apart from
the three line separators ``dumps`` escapes, so a ``separators`` caller loses
stdlib's ASCII escaping and an ``ensure_ascii=False`` caller loses its
spacing. ``dumps`` also serializes ``datetime``/``UUID``/dataclasses that
stdlib refuses, and encodes ``NaN``/``Infinity`` as ``null``. ``loads``
decodes integers above ``2**64-1`` or below ``-2**63`` as ``float`` and does
not enforce engineio's 100-digit integer-literal limit.
What orjson does reject (non-str dict keys and oversized ints on ``dumps``,
the ``NaN``/``Infinity`` literals on ``loads``) falls back to engineio's
stdlib-based codec, and with it stdlib's formatting.
"""
JSONDecodeError = engineio_json.JSONDecodeError
@staticmethod
def dumps(obj, *args, **kwargs):
if args or (kwargs and kwargs not in FAST_PATH_KWARGS):
return engineio_json.dumps(obj, *args, **kwargs)
try:
fix: escape line separators in orjson output (#27819) orjson emits U+2028, U+2029 and U+0085 raw, where stdlib `json.dumps` escapes them under its default `ensure_ascii=True`. Python treats all three as line boundaries, so with `ENABLE_ORJSON` set, one of them inside model output splits a `data: {...}` SSE frame in half. Both halves then fail to parse and the delta is dropped with no error. `utils/middleware.py` reassembles frames with `splitlines()`, so an affected response silently loses content on the direct API path. External clients are exposed as well: httpx's `LineDecoder` reimplements the same line-boundary semantics, so any SDK reading the OpenAI-compatible stream through `aiter_lines` breaks on a raw separator. The three characters are escaped on the way out of `ORJSONCodec.dumps`. That restores parity with stdlib and fixes every reader at once, rather than patching one consumer and leaving external clients broken. They are the complete set: of the ten code points `splitlines()` treats as boundaries, the other seven are below U+0020, where JSON already forces an escape. The membership guard is load bearing. Calling `translate` unconditionally costs roughly 1.5 us on a typical SSE chunk against 0.115 us for the serialization it wraps, so it would spend more than orjson saves. The three scans cost about 0.04 us. Payloads containing none of the three are returned unchanged, byte for byte. With `ENABLE_ORJSON` unset, which is the default, none of this code runs. U+2028 and U+2029 are common in text extracted from PDFs and word processor documents, so the realistic trigger is a model quoting an uploaded file back to the user.
2026-07-31 23:25:30 +02:00
serialized = orjson.dumps(obj).decode('utf-8')
except (TypeError, ValueError):
return engineio_json.dumps(obj, *args, **kwargs)
# Raw, these three split an SSE frame reassembled with ``splitlines()``.
# A dict-table translate walks char by char; chained replace runs on C fast paths.
fix: escape line separators in orjson output (#27819) orjson emits U+2028, U+2029 and U+0085 raw, where stdlib `json.dumps` escapes them under its default `ensure_ascii=True`. Python treats all three as line boundaries, so with `ENABLE_ORJSON` set, one of them inside model output splits a `data: {...}` SSE frame in half. Both halves then fail to parse and the delta is dropped with no error. `utils/middleware.py` reassembles frames with `splitlines()`, so an affected response silently loses content on the direct API path. External clients are exposed as well: httpx's `LineDecoder` reimplements the same line-boundary semantics, so any SDK reading the OpenAI-compatible stream through `aiter_lines` breaks on a raw separator. The three characters are escaped on the way out of `ORJSONCodec.dumps`. That restores parity with stdlib and fixes every reader at once, rather than patching one consumer and leaving external clients broken. They are the complete set: of the ten code points `splitlines()` treats as boundaries, the other seven are below U+0020, where JSON already forces an escape. The membership guard is load bearing. Calling `translate` unconditionally costs roughly 1.5 us on a typical SSE chunk against 0.115 us for the serialization it wraps, so it would spend more than orjson saves. The three scans cost about 0.04 us. Payloads containing none of the three are returned unchanged, byte for byte. With `ENABLE_ORJSON` unset, which is the default, none of this code runs. U+2028 and U+2029 are common in text extracted from PDFs and word processor documents, so the realistic trigger is a model quoting an uploaded file back to the user.
2026-07-31 23:25:30 +02:00
if '\u2028' in serialized or '\u2029' in serialized or '\x85' in serialized:
return serialized.replace('\u2028', '\\u2028').replace('\u2029', '\\u2029').replace('\x85', '\\u0085')
fix: escape line separators in orjson output (#27819) orjson emits U+2028, U+2029 and U+0085 raw, where stdlib `json.dumps` escapes them under its default `ensure_ascii=True`. Python treats all three as line boundaries, so with `ENABLE_ORJSON` set, one of them inside model output splits a `data: {...}` SSE frame in half. Both halves then fail to parse and the delta is dropped with no error. `utils/middleware.py` reassembles frames with `splitlines()`, so an affected response silently loses content on the direct API path. External clients are exposed as well: httpx's `LineDecoder` reimplements the same line-boundary semantics, so any SDK reading the OpenAI-compatible stream through `aiter_lines` breaks on a raw separator. The three characters are escaped on the way out of `ORJSONCodec.dumps`. That restores parity with stdlib and fixes every reader at once, rather than patching one consumer and leaving external clients broken. They are the complete set: of the ten code points `splitlines()` treats as boundaries, the other seven are below U+0020, where JSON already forces an escape. The membership guard is load bearing. Calling `translate` unconditionally costs roughly 1.5 us on a typical SSE chunk against 0.115 us for the serialization it wraps, so it would spend more than orjson saves. The three scans cost about 0.04 us. Payloads containing none of the three are returned unchanged, byte for byte. With `ENABLE_ORJSON` unset, which is the default, none of this code runs. U+2028 and U+2029 are common in text extracted from PDFs and word processor documents, so the realistic trigger is a model quoting an uploaded file back to the user.
2026-07-31 23:25:30 +02:00
return serialized
@staticmethod
def loads(s, *args, **kwargs):
if args or kwargs:
return engineio_json.loads(s, *args, **kwargs)
try:
return orjson.loads(s)
except (TypeError, ValueError):
return engineio_json.loads(s, *args, **kwargs)
# Drop-in for stdlib ``json``: ``JSONCodec.dumps`` / ``JSONCodec.loads``.
JSONCodec = ORJSONCodec
# Codec handed to the socket.io/engineio managers, which default to their own.
SOCKETIO_JSON = ORJSONCodec
def dumps_bytes(obj) -> bytes:
"""JSON as UTF-8 bytes, skipping the str round trip and the escaping ``dumps`` does."""
try:
return orjson.dumps(obj)
except (TypeError, ValueError):
return engineio_json.dumps(obj).encode('utf-8')
else:
JSONCodec = stdlib_json
SOCKETIO_JSON = engineio_json
def dumps_bytes(obj) -> bytes:
"""JSON as UTF-8 bytes; here simply ``dumps`` encoded."""
return stdlib_json.dumps(obj).encode('utf-8')