perf: optional orjson JSON codec behind ENABLE_ORJSON (#27583)
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
This commit is contained in:
1 parent
602004dd5f
commit
72fdf238a8
13 files changed
+176
-51
No files matched your search
@@ -150,6 +150,11 @@ INSTANCE_ID = os.getenv('INSTANCE_ID', str(uuid4()))
|
|||||||
|
|
||||||
ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true'
|
ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true'
|
||||||
|
|
||||||
|
# Swap the JSON encoder/decoder used across the app (HTTP request bodies, JSONResponse
|
||||||
|
# bodies, upstream provider responses, socket.io payloads) from the stdlib `json` module
|
||||||
|
# to orjson. Faster, but stricter: see open_webui/utils/json_codec.py for the differences.
|
||||||
|
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
# Function to parse each section
|
# Function to parse each section
|
||||||
def parse_section(section):
|
def parse_section(section):
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ from open_webui.utils.chat_variables import (
|
|||||||
normalize_chat_variables,
|
normalize_chat_variables,
|
||||||
)
|
)
|
||||||
from open_webui.utils.embeddings import generate_embeddings
|
from open_webui.utils.embeddings import generate_embeddings
|
||||||
|
from open_webui.utils.json_response import apply_orjson_http_json
|
||||||
from open_webui.utils.logger import start_logger
|
from open_webui.utils.logger import start_logger
|
||||||
from open_webui.utils.middleware import (
|
from open_webui.utils.middleware import (
|
||||||
background_tasks_handler,
|
background_tasks_handler,
|
||||||
@@ -456,6 +457,10 @@ async def lifespan(app: FastAPI):
|
|||||||
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
|
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
|
||||||
|
|
||||||
|
|
||||||
|
# Opt-in (ENABLE_ORJSON): orjson for request-body parsing and JSONResponse bodies;
|
||||||
|
# response_model routes keep FastAPI's Pydantic fast path either way.
|
||||||
|
apply_orjson_http_json()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title='Open WebUI',
|
title='Open WebUI',
|
||||||
docs_url='/docs' if ENV == 'dev' else None,
|
docs_url='/docs' if ENV == 'dev' else None,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from open_webui.utils.access_control import check_model_access
|
|||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||||
|
from open_webui.utils.json_codec import JSONCodec
|
||||||
from open_webui.utils.misc import calculate_sha256
|
from open_webui.utils.misc import calculate_sha256
|
||||||
from open_webui.utils.payload import (
|
from open_webui.utils.payload import (
|
||||||
apply_model_params_to_body_ollama,
|
apply_model_params_to_body_ollama,
|
||||||
@@ -86,7 +87,7 @@ async def send_get_request(
|
|||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||||
) as r:
|
) as r:
|
||||||
return await r.json()
|
return await r.json(loads=JSONCodec.loads)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error(f'Connection error: {exc}')
|
log.error(f'Connection error: {exc}')
|
||||||
return None
|
return None
|
||||||
@@ -137,7 +138,7 @@ async def send_request(
|
|||||||
|
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
try:
|
try:
|
||||||
res = await r.json()
|
res = await r.json(loads=JSONCodec.loads)
|
||||||
await publish_model_provider_request_failed(
|
await publish_model_provider_request_failed(
|
||||||
request,
|
request,
|
||||||
actor=user,
|
actor=user,
|
||||||
@@ -179,7 +180,7 @@ async def send_request(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
return await r.json()
|
return await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -270,12 +271,12 @@ async def verify_connection(
|
|||||||
) as r:
|
) as r:
|
||||||
if r.status != 200:
|
if r.status != 200:
|
||||||
detail = f'HTTP Error: {r.status}'
|
detail = f'HTTP Error: {r.status}'
|
||||||
res = await r.json()
|
res = await r.json(loads=JSONCodec.loads)
|
||||||
if 'error' in res:
|
if 'error' in res:
|
||||||
detail = f'External Error: {res["error"]}'
|
detail = f'External Error: {res["error"]}'
|
||||||
raise Exception(detail)
|
raise Exception(detail)
|
||||||
|
|
||||||
return await r.json()
|
return await r.json(loads=JSONCodec.loads)
|
||||||
except aiohttp.ClientError as exc:
|
except aiohttp.ClientError as exc:
|
||||||
log.exception(f'Client error: {exc}')
|
log.exception(f'Client error: {exc}')
|
||||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from open_webui.utils.access_control import check_model_access, has_connection_a
|
|||||||
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
|
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||||
|
from open_webui.utils.json_codec import JSONCodec
|
||||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||||
from open_webui.utils.misc import (
|
from open_webui.utils.misc import (
|
||||||
convert_logit_bias_input_to_json,
|
convert_logit_bias_input_to_json,
|
||||||
@@ -112,7 +113,7 @@ async def send_get_request(
|
|||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as response:
|
) as response:
|
||||||
return await response.json()
|
return await response.json(loads=JSONCodec.loads)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Handle connection error here
|
# Handle connection error here
|
||||||
log.error(f'Connection error: {e}')
|
log.error(f'Connection error: {e}')
|
||||||
@@ -357,7 +358,7 @@ async def count_anthropic_tokens(request: Request, form_data: dict, user: UserMo
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_data = await response.json()
|
response_data = await response.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await response.text()
|
response_data = await response.text()
|
||||||
|
|
||||||
@@ -509,7 +510,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
detail = None
|
detail = None
|
||||||
if r is not None:
|
if r is not None:
|
||||||
try:
|
try:
|
||||||
res = await r.json()
|
res = await r.json(loads=JSONCodec.loads)
|
||||||
if 'error' in res:
|
if 'error' in res:
|
||||||
detail = f'External: {res["error"]}'
|
detail = f'External: {res["error"]}'
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -763,14 +764,14 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
|
|||||||
if r.status != 200:
|
if r.status != 200:
|
||||||
error_detail = f'HTTP Error: {r.status}'
|
error_detail = f'HTTP Error: {r.status}'
|
||||||
try:
|
try:
|
||||||
res = await r.json()
|
res = await r.json(loads=JSONCodec.loads)
|
||||||
if 'error' in res:
|
if 'error' in res:
|
||||||
error_detail = f'External Error: {res["error"]}'
|
error_detail = f'External Error: {res["error"]}'
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise Exception(error_detail)
|
raise Exception(error_detail)
|
||||||
|
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
|
|
||||||
if 'api.openai.com' in url:
|
if 'api.openai.com' in url:
|
||||||
response_data['data'] = [
|
response_data['data'] = [
|
||||||
@@ -853,7 +854,7 @@ async def verify_connection(
|
|||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
) as r:
|
||||||
try:
|
try:
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await r.text()
|
response_data = await r.text()
|
||||||
|
|
||||||
@@ -879,7 +880,7 @@ async def verify_connection(
|
|||||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
) as r:
|
) as r:
|
||||||
try:
|
try:
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await r.text()
|
response_data = await r.text()
|
||||||
|
|
||||||
@@ -1417,7 +1418,7 @@ async def generate_chat_completion(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response = await r.json()
|
response = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(e)
|
log.error(e)
|
||||||
response = await r.text()
|
response = await r.text()
|
||||||
@@ -1529,7 +1530,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await r.text()
|
response_data = await r.text()
|
||||||
|
|
||||||
@@ -1656,7 +1657,7 @@ async def responses(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await r.text()
|
response_data = await r.text()
|
||||||
|
|
||||||
@@ -1778,7 +1779,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response_data = await r.json()
|
response_data = await r.json(loads=JSONCodec.loads)
|
||||||
except Exception:
|
except Exception:
|
||||||
response_data = await r.text()
|
response_data = await r.text()
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from open_webui.tasks import create_task, stop_item_tasks
|
|||||||
from open_webui.utils.access_control import has_permission
|
from open_webui.utils.access_control import has_permission
|
||||||
from open_webui.utils.auth import get_verified_user_by_token
|
from open_webui.utils.auth import get_verified_user_by_token
|
||||||
from open_webui.utils.chat_id import is_saved_chat_id
|
from open_webui.utils.chat_id import is_saved_chat_id
|
||||||
|
from open_webui.utils.json_codec import SOCKETIO_JSON
|
||||||
from open_webui.utils.misc import get_output_text
|
from open_webui.utils.misc import get_output_text
|
||||||
from open_webui.utils.redis import (
|
from open_webui.utils.redis import (
|
||||||
build_sentinel_url,
|
build_sentinel_url,
|
||||||
@@ -70,10 +71,11 @@ if WEBSOCKET_MANAGER == 'redis':
|
|||||||
if sentinel_hosts
|
if sentinel_hosts
|
||||||
else WEBSOCKET_REDIS_URL
|
else WEBSOCKET_REDIS_URL
|
||||||
)
|
)
|
||||||
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS, json=SOCKETIO_JSON)
|
||||||
sio = socketio.AsyncServer(
|
sio = socketio.AsyncServer(
|
||||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||||
async_mode='asgi',
|
async_mode='asgi',
|
||||||
|
json=SOCKETIO_JSON,
|
||||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||||
always_connect=True,
|
always_connect=True,
|
||||||
@@ -87,6 +89,7 @@ else:
|
|||||||
sio = socketio.AsyncServer(
|
sio = socketio.AsyncServer(
|
||||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||||
async_mode='asgi',
|
async_mode='asgi',
|
||||||
|
json=SOCKETIO_JSON,
|
||||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||||
always_connect=True,
|
always_connect=True,
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pycrdt as Y
|
import pycrdt as Y
|
||||||
from open_webui.env import REDIS_KEY_PREFIX
|
from open_webui.env import REDIS_KEY_PREFIX
|
||||||
|
from open_webui.utils.json_codec import JSONCodec
|
||||||
from open_webui.utils.redis import get_redis_connection
|
from open_webui.utils.redis import get_redis_connection
|
||||||
|
|
||||||
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
||||||
@@ -75,14 +75,14 @@ class RedisDict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
serialized_value = json.dumps(value)
|
serialized_value = JSONCodec.dumps(value)
|
||||||
self.redis.hset(self.name, key, serialized_value)
|
self.redis.hset(self.name, key, serialized_value)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
value = self.redis.hget(self.name, key)
|
value = self.redis.hget(self.name, key)
|
||||||
if value is None:
|
if value is None:
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
return json.loads(value)
|
return JSONCodec.loads(value)
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key):
|
||||||
result = self.redis.hdel(self.name, key)
|
result = self.redis.hdel(self.name, key)
|
||||||
@@ -99,10 +99,10 @@ class RedisDict:
|
|||||||
return self.redis.hkeys(self.name)
|
return self.redis.hkeys(self.name)
|
||||||
|
|
||||||
def values(self):
|
def values(self):
|
||||||
return [json.loads(v) for v in self.redis.hvals(self.name)]
|
return [JSONCodec.loads(v) for v in self.redis.hvals(self.name)]
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
||||||
|
|
||||||
def set(self, mapping: dict):
|
def set(self, mapping: dict):
|
||||||
if not mapping:
|
if not mapping:
|
||||||
@@ -111,7 +111,7 @@ class RedisDict:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Serialize values once — reused for both the fingerprint and the write.
|
# Serialize values once — reused for both the fingerprint and the write.
|
||||||
serialized = {k: json.dumps(v) for k, v in mapping.items()}
|
serialized = {k: JSONCodec.dumps(v) for k, v in mapping.items()}
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
for key in sorted(serialized):
|
for key in sorted(serialized):
|
||||||
digest.update(key.encode())
|
digest.update(key.encode())
|
||||||
@@ -182,7 +182,7 @@ class YdocManager:
|
|||||||
document_id = document_id.replace(':', '_')
|
document_id = document_id.replace(':', '_')
|
||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
||||||
await self._redis.rpush(redis_key, json.dumps(list(update)))
|
await self._redis.rpush(redis_key, JSONCodec.dumps(list(update)))
|
||||||
list_len = await self._redis.llen(redis_key)
|
list_len = await self._redis.llen(redis_key)
|
||||||
if list_len >= self.COMPACTION_THRESHOLD:
|
if list_len >= self.COMPACTION_THRESHOLD:
|
||||||
await self._compact_updates_redis(document_id)
|
await self._compact_updates_redis(document_id)
|
||||||
@@ -202,8 +202,8 @@ class YdocManager:
|
|||||||
mid = len(all_updates) // 2
|
mid = len(all_updates) // 2
|
||||||
ydoc = Y.Doc()
|
ydoc = Y.Doc()
|
||||||
for raw in all_updates[:mid]:
|
for raw in all_updates[:mid]:
|
||||||
ydoc.apply_update(bytes(json.loads(raw)))
|
ydoc.apply_update(bytes(JSONCodec.loads(raw)))
|
||||||
snapshot = json.dumps(list(ydoc.get_update()))
|
snapshot = JSONCodec.dumps(list(ydoc.get_update()))
|
||||||
pipe = self._redis.pipeline()
|
pipe = self._redis.pipeline()
|
||||||
pipe.delete(redis_key)
|
pipe.delete(redis_key)
|
||||||
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
|
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
|
||||||
@@ -226,7 +226,7 @@ class YdocManager:
|
|||||||
if self._redis:
|
if self._redis:
|
||||||
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
||||||
updates = await self._redis.lrange(redis_key, 0, -1)
|
updates = await self._redis.lrange(redis_key, 0, -1)
|
||||||
return [bytes(json.loads(update)) for update in updates]
|
return [bytes(JSONCodec.loads(update)) for update in updates]
|
||||||
else:
|
else:
|
||||||
return self._updates.get(document_id, [])
|
return self._updates.get(document_id, [])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
class ORJSONCodec:
|
||||||
|
"""stdlib-``json``-compatible codec backed by orjson.
|
||||||
|
|
||||||
|
Anything orjson rejects (non-str dict keys, ints beyond 64 bits, ``NaN``
|
||||||
|
literals) falls back to engineio's stdlib-based codec, which keeps its
|
||||||
|
oversized-integer guard for untrusted client payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
JSONDecodeError = engineio_json.JSONDecodeError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dumps(obj, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return orjson.dumps(obj).decode('utf-8')
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return engineio_json.dumps(obj, *args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def 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
|
||||||
|
else:
|
||||||
|
JSONCodec = stdlib_json
|
||||||
|
SOCKETIO_JSON = engineio_json
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""orjson-backed JSON parsing/rendering for starlette/FastAPI requests and responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from open_webui.env import ENABLE_ORJSON
|
||||||
|
|
||||||
|
|
||||||
|
def apply_orjson_http_json() -> None:
|
||||||
|
"""Parse request bodies and serialize ``JSONResponse`` with orjson.
|
||||||
|
|
||||||
|
A no-op unless ``ENABLE_ORJSON`` is set, leaving starlette's own
|
||||||
|
stdlib-``json`` implementations untouched.
|
||||||
|
|
||||||
|
Not ``FastAPI(default_response_class=...)`` on purpose: an explicit
|
||||||
|
default disables FastAPI's Pydantic direct-to-bytes fast path for
|
||||||
|
``response_model`` routes. NaN/Infinity floats serialize as ``null``
|
||||||
|
instead of raising.
|
||||||
|
"""
|
||||||
|
if not ENABLE_ORJSON:
|
||||||
|
return
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
|
||||||
|
def render(self, content: Any) -> bytes:
|
||||||
|
try:
|
||||||
|
return orjson.dumps(content)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Fallback matches starlette's JSONResponse.render exactly.
|
||||||
|
return json.dumps(
|
||||||
|
content,
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
indent=None,
|
||||||
|
separators=(',', ':'),
|
||||||
|
).encode('utf-8')
|
||||||
|
|
||||||
|
async def request_json(self) -> Any:
|
||||||
|
if not hasattr(self, '_json'):
|
||||||
|
body = await self.body()
|
||||||
|
try:
|
||||||
|
self._json = orjson.loads(body)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Fallback matches starlette's Request.json exactly, including the
|
||||||
|
# json.JSONDecodeError that FastAPI turns into a 422.
|
||||||
|
self._json = json.loads(body)
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
JSONResponse.render = render
|
||||||
|
Request.json = request_json
|
||||||
@@ -94,6 +94,7 @@ from open_webui.utils.filter import (
|
|||||||
process_filter_functions,
|
process_filter_functions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from open_webui.utils.json_codec import JSONCodec
|
||||||
from open_webui.utils.mcp.client import MCPClient
|
from open_webui.utils.mcp.client import MCPClient
|
||||||
from open_webui.utils.memory import add_memory_context, review_memory_after_turn
|
from open_webui.utils.memory import add_memory_context, review_memory_after_turn
|
||||||
from open_webui.utils.misc import (
|
from open_webui.utils.misc import (
|
||||||
@@ -299,7 +300,7 @@ def get_citation_source_from_tool_result(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
tool_result = json.loads(tool_result)
|
tool_result = JSONCodec.loads(tool_result)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass # keep tool_result as-is (e.g. fetch_url returns plain text)
|
pass # keep tool_result as-is (e.g. fetch_url returns plain text)
|
||||||
if isinstance(tool_result, dict) and 'error' in tool_result:
|
if isinstance(tool_result, dict) and 'error' in tool_result:
|
||||||
@@ -1000,7 +1001,7 @@ async def process_tool_result(
|
|||||||
text = item.get('text', '')
|
text = item.get('text', '')
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
try:
|
try:
|
||||||
text = json.loads(text)
|
text = JSONCodec.loads(text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
tool_response.append(text)
|
tool_response.append(text)
|
||||||
@@ -1028,7 +1029,7 @@ async def process_tool_result(
|
|||||||
text = resource.get('text', '')
|
text = resource.get('text', '')
|
||||||
if isinstance(text, str) and text:
|
if isinstance(text, str) and text:
|
||||||
try:
|
try:
|
||||||
text = json.loads(text)
|
text = JSONCodec.loads(text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
tool_response.append(text)
|
tool_response.append(text)
|
||||||
@@ -1103,7 +1104,7 @@ async def terminal_event_handler(
|
|||||||
parsed = tool_result
|
parsed = tool_result
|
||||||
if isinstance(parsed, str):
|
if isinstance(parsed, str):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(parsed)
|
parsed = JSONCodec.loads(parsed)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
if isinstance(parsed, dict) and parsed.get('exists') is False:
|
if isinstance(parsed, dict) and parsed.get('exists') is False:
|
||||||
@@ -1141,7 +1142,7 @@ async def chat_completion_tools_handler(
|
|||||||
content = None
|
content = None
|
||||||
if hasattr(response, 'body_iterator'):
|
if hasattr(response, 'body_iterator'):
|
||||||
async for chunk in response.body_iterator:
|
async for chunk in response.body_iterator:
|
||||||
data = json.loads(chunk.decode('utf-8', 'replace'))
|
data = JSONCodec.loads(chunk.decode('utf-8', 'replace'))
|
||||||
content = data['choices'][0]['message']['content']
|
content = data['choices'][0]['message']['content']
|
||||||
|
|
||||||
# Cleanup any remaining background tasks if necessary
|
# Cleanup any remaining background tasks if necessary
|
||||||
@@ -1221,7 +1222,7 @@ async def chat_completion_tools_handler(
|
|||||||
if not content:
|
if not content:
|
||||||
raise Exception('No JSON object found in the response')
|
raise Exception('No JSON object found in the response')
|
||||||
|
|
||||||
result = json.loads(content)
|
result = JSONCodec.loads(content)
|
||||||
|
|
||||||
async def tool_call_handler(tool_call):
|
async def tool_call_handler(tool_call):
|
||||||
nonlocal skip_files
|
nonlocal skip_files
|
||||||
@@ -1390,7 +1391,7 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
|
|||||||
# user message as the search query.
|
# user message as the search query.
|
||||||
if isinstance(res, JSONResponse):
|
if isinstance(res, JSONResponse):
|
||||||
try:
|
try:
|
||||||
error_body = json.loads(res.body)
|
error_body = JSONCodec.loads(res.body)
|
||||||
detail = error_body.get('detail', 'Query generation failed')
|
detail = error_body.get('detail', 'Query generation failed')
|
||||||
except Exception:
|
except Exception:
|
||||||
detail = 'Query generation failed'
|
detail = 'Query generation failed'
|
||||||
@@ -1406,7 +1407,7 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
|
|||||||
raise Exception('No JSON object found in the response')
|
raise Exception('No JSON object found in the response')
|
||||||
|
|
||||||
response = response[bracket_start:bracket_end]
|
response = response[bracket_start:bracket_end]
|
||||||
queries = json.loads(response)
|
queries = JSONCodec.loads(response)
|
||||||
queries = queries.get('queries', [])
|
queries = queries.get('queries', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
queries = [response]
|
queries = [response]
|
||||||
@@ -1735,7 +1736,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||||||
# Handle JSONResponse from error paths
|
# Handle JSONResponse from error paths
|
||||||
if isinstance(res, JSONResponse):
|
if isinstance(res, JSONResponse):
|
||||||
try:
|
try:
|
||||||
error_body = json.loads(res.body)
|
error_body = JSONCodec.loads(res.body)
|
||||||
detail = error_body.get('detail', 'Image prompt generation failed')
|
detail = error_body.get('detail', 'Image prompt generation failed')
|
||||||
except Exception:
|
except Exception:
|
||||||
detail = 'Image prompt generation failed'
|
detail = 'Image prompt generation failed'
|
||||||
@@ -1751,7 +1752,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||||||
raise Exception('No JSON object found in the response')
|
raise Exception('No JSON object found in the response')
|
||||||
|
|
||||||
response = response[bracket_start:bracket_end]
|
response = response[bracket_start:bracket_end]
|
||||||
response = json.loads(response)
|
response = JSONCodec.loads(response)
|
||||||
prompt = response.get('prompt', [])
|
prompt = response.get('prompt', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
prompt = user_message
|
prompt = user_message
|
||||||
@@ -1855,7 +1856,7 @@ async def chat_completion_files_handler(
|
|||||||
raise Exception('No JSON object found in the response')
|
raise Exception('No JSON object found in the response')
|
||||||
|
|
||||||
queries_response = queries_response[bracket_start:bracket_end]
|
queries_response = queries_response[bracket_start:bracket_end]
|
||||||
queries_response = json.loads(queries_response)
|
queries_response = JSONCodec.loads(queries_response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
queries_response = {'queries': [queries_response]}
|
queries_response = {'queries': [queries_response]}
|
||||||
|
|
||||||
@@ -1966,7 +1967,7 @@ def apply_params_to_form_data(form_data, model):
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
try:
|
try:
|
||||||
# Attempt to parse the string as JSON
|
# Attempt to parse the string as JSON
|
||||||
custom_params[key] = json.loads(value)
|
custom_params[key] = JSONCodec.loads(value)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# If it fails, keep the original string
|
# If it fails, keep the original string
|
||||||
pass
|
pass
|
||||||
@@ -1988,7 +1989,7 @@ def apply_params_to_form_data(form_data, model):
|
|||||||
logit_bias = convert_logit_bias_input_to_json(params['logit_bias'])
|
logit_bias = convert_logit_bias_input_to_json(params['logit_bias'])
|
||||||
|
|
||||||
if logit_bias:
|
if logit_bias:
|
||||||
form_data['logit_bias'] = json.loads(logit_bias)
|
form_data['logit_bias'] = JSONCodec.loads(logit_bias)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f'Error parsing logit_bias: {e}')
|
log.exception(f'Error parsing logit_bias: {e}')
|
||||||
|
|
||||||
@@ -3031,7 +3032,7 @@ def get_response_data(response):
|
|||||||
if isinstance(response, JSONResponse):
|
if isinstance(response, JSONResponse):
|
||||||
if isinstance(response.body, bytes):
|
if isinstance(response.body, bytes):
|
||||||
try:
|
try:
|
||||||
response_data = json.loads(response.body.decode('utf-8', 'replace'))
|
response_data = JSONCodec.loads(response.body.decode('utf-8', 'replace'))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
response_data = {'error': {'detail': 'Invalid JSON response'}}
|
response_data = {'error': {'detail': 'Invalid JSON response'}}
|
||||||
else:
|
else:
|
||||||
@@ -3090,7 +3091,7 @@ def update_assistant_message_from_stream(assistant_message, raw):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(part)
|
data = JSONCodec.loads(part)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -3278,7 +3279,7 @@ async def background_tasks_handler(ctx):
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
follow_ups = json.loads(follow_ups_string).get('follow_ups', [])
|
follow_ups = JSONCodec.loads(follow_ups_string).get('follow_ups', [])
|
||||||
await event_emitter(
|
await event_emitter(
|
||||||
{
|
{
|
||||||
'type': 'chat:message:follow_ups',
|
'type': 'chat:message:follow_ups',
|
||||||
@@ -3336,7 +3337,7 @@ async def background_tasks_handler(ctx):
|
|||||||
title_string = title_string[title_string.find('{') : title_string.rfind('}') + 1]
|
title_string = title_string[title_string.find('{') : title_string.rfind('}') + 1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = json.loads(title_string).get('title', user_message)
|
title = JSONCodec.loads(title_string).get('title', user_message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
title = ''
|
title = ''
|
||||||
|
|
||||||
@@ -3388,7 +3389,7 @@ async def background_tasks_handler(ctx):
|
|||||||
tags_string = tags_string[tags_string.find('{') : tags_string.rfind('}') + 1]
|
tags_string = tags_string[tags_string.find('{') : tags_string.rfind('}') + 1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tags = json.loads(tags_string).get('tags', [])
|
tags = JSONCodec.loads(tags_string).get('tags', [])
|
||||||
await Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user)
|
await Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user)
|
||||||
|
|
||||||
await event_emitter(
|
await event_emitter(
|
||||||
@@ -4212,7 +4213,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||||||
# (without SSE `data:` prefix). Try to normalize these into standard
|
# (without SSE `data:` prefix). Try to normalize these into standard
|
||||||
# error events so frontend and DB paths still receive them.
|
# error events so frontend and DB paths still receive them.
|
||||||
try:
|
try:
|
||||||
raw_obj = json.loads(data)
|
raw_obj = JSONCodec.loads(data)
|
||||||
raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None
|
raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None
|
||||||
if raw_error:
|
if raw_error:
|
||||||
if save_to_chat:
|
if save_to_chat:
|
||||||
@@ -4235,7 +4236,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||||||
data = data[5:].strip()
|
data = data[5:].strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(data)
|
data = JSONCodec.loads(data)
|
||||||
|
|
||||||
if filter_functions:
|
if filter_functions:
|
||||||
data, _ = await process_filter_functions(
|
data, _ = await process_filter_functions(
|
||||||
@@ -4937,7 +4938,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||||||
params = {}
|
params = {}
|
||||||
if tool_args and tool_args.strip():
|
if tool_args and tool_args.strip():
|
||||||
try:
|
try:
|
||||||
params = json.loads(tool_args)
|
params = JSONCodec.loads(tool_args)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
params = ast.literal_eval(tool_args)
|
params = ast.literal_eval(tool_args)
|
||||||
@@ -5613,7 +5614,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if event:
|
if event:
|
||||||
yield wrap_item(json.dumps(event))
|
yield wrap_item(JSONCodec.dumps(event))
|
||||||
|
|
||||||
async for data in original_generator:
|
async for data in original_generator:
|
||||||
data, _ = await process_filter_functions(
|
data, _ = await process_filter_functions(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
from numbers import Number
|
from numbers import Number
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from open_webui.utils.json_codec import JSONCodec
|
||||||
from open_webui.utils.misc import (
|
from open_webui.utils.misc import (
|
||||||
openai_chat_chunk_message_template,
|
openai_chat_chunk_message_template,
|
||||||
openai_chat_completion_message_template,
|
openai_chat_completion_message_template,
|
||||||
@@ -237,7 +238,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
|||||||
completion_id = f'chatcmpl-{str(uuid4())}'
|
completion_id = f'chatcmpl-{str(uuid4())}'
|
||||||
first = True
|
first = True
|
||||||
async for data in ollama_streaming_response.body_iterator:
|
async for data in ollama_streaming_response.body_iterator:
|
||||||
data = json.loads(data)
|
data = JSONCodec.loads(data)
|
||||||
|
|
||||||
model = data.get('model', 'ollama')
|
model = data.get('model', 'ollama')
|
||||||
message = data.get('message') or {}
|
message = data.get('message') or {}
|
||||||
@@ -268,7 +269,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
|||||||
if done and has_tool_calls:
|
if done and has_tool_calls:
|
||||||
data['choices'][0]['finish_reason'] = 'tool_calls'
|
data['choices'][0]['finish_reason'] = 'tool_calls'
|
||||||
|
|
||||||
line = f'data: {json.dumps(data)}\n\n'
|
line = f'data: {JSONCodec.dumps(data)}\n\n'
|
||||||
yield line
|
yield line
|
||||||
|
|
||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ python-multipart==0.0.32
|
|||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
|
||||||
python-socketio==5.16.2
|
python-socketio==5.16.2
|
||||||
|
orjson==3.11.9
|
||||||
cryptography
|
cryptography
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ python-multipart==0.0.32
|
|||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
|
||||||
python-socketio==5.16.2
|
python-socketio==5.16.2
|
||||||
|
orjson==3.11.9
|
||||||
cryptography==48.0.0
|
cryptography==48.0.0
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"itsdangerous==2.2.0",
|
"itsdangerous==2.2.0",
|
||||||
|
|
||||||
"python-socketio==5.16.2",
|
"python-socketio==5.16.2",
|
||||||
|
"orjson==3.11.9",
|
||||||
"cryptography==48.0.0",
|
"cryptography==48.0.0",
|
||||||
"bcrypt==5.0.0",
|
"bcrypt==5.0.0",
|
||||||
"argon2-cffi==25.1.0",
|
"argon2-cffi==25.1.0",
|
||||||
|
|||||||
Reference in new issue
Block a user