From 220f39be5a71285cfdce56447ab8c9b4bcf79a58 Mon Sep 17 00:00:00 2001 From: "xy.kong" Date: Sun, 22 Mar 2026 02:39:51 +0800 Subject: [PATCH] fix(core): add strip_null_bytes() to safe_dumps to prevent PostgreSQL 22P05 errors Null bytes (\x00) in LLM request/response payloads cause PostgreSQL to raise '22P05: invalid byte sequence for encoding UTF8: 0x00' when spend logs are written to the database. Changes: - Add strip_null_bytes() helper to safe_json_dumps.py that recursively removes \x00 chars from strings, dicts, lists, tuples and sets - Inline null byte removal into safe_dumps() _serialize() for str paths so all JSON serialization through safe_dumps() is automatically safe - In spend_tracking_utils.py: replace json.dumps() with safe_dumps() for messages and request_body serialization; add strip_null_bytes() call in _sanitize_request_body_for_spend_logs_payload string handling Centralizing the fix in safe_dumps() is more robust than ad-hoc stripping at each call site. Fixes #24310 Related: #21290, #15519 --- litellm/litellm_core_utils/safe_json_dumps.py | 22 +++++++++++++++++-- .../spend_tracking/spend_tracking_utils.py | 7 +++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 051aa2f27a5..6658d6c5ba5 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -6,10 +6,26 @@ from pydantic import BaseModel from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +def strip_null_bytes(data: Any) -> Any: + """Recursively remove \\x00 null bytes from strings to prevent PostgreSQL 22P05 errors.""" + if isinstance(data, str): + return data.replace("\x00", "") + if isinstance(data, dict): + return {k: strip_null_bytes(v) for k, v in data.items()} + if isinstance(data, list): + return [strip_null_bytes(item) for item in data] + if isinstance(data, tuple): + return tuple(strip_null_bytes(item) for item in data) + if isinstance(data, set): + return {strip_null_bytes(item) for item in data} + return data + + def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: """ Recursively serialize data while detecting circular references. If a circular reference is detected then a marker string is returned. + Null bytes are stripped from strings to prevent PostgreSQL 22P05 errors. """ def _serialize(obj: Any, seen: set, depth: int) -> Any: @@ -17,7 +33,9 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: if depth > max_depth: return "MaxDepthExceeded" # Base-case: if it is a primitive, simply return it. - if isinstance(obj, (str, int, float, bool, type(None))): + if isinstance(obj, str): + return obj.replace("\x00", "") + if isinstance(obj, (int, float, bool, type(None))): return obj # Check for circular reference. if id(obj) in seen: @@ -51,7 +69,7 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: else: # Fall back to string conversion for non-serializable objects. try: - return str(obj) + return str(obj).replace("\x00", "") except Exception: return "Unserializable Object" diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 3eacc19a6df..25a0fe9d455 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -23,7 +23,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_litellm_metadata_from_kwargs, reconstruct_model_name, ) -from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload from litellm.proxy.utils import PrismaClient, hash_token from litellm.types.utils import ( @@ -621,7 +621,7 @@ def _get_messages_for_spend_logs_payload( messages = standard_logging_payload.get("messages") if messages is not None: try: - return json.dumps(messages, default=str) + return safe_dumps(messages) except Exception: return "{}" return "{}" @@ -660,6 +660,7 @@ def _sanitize_request_body_for_spend_logs_payload( elif isinstance(value, list): return [_sanitize_value(item) for item in value] elif isinstance(value, str): + value = strip_null_bytes(value) if len(value) > max_string_length_prompt_in_db: # Keep 35% from beginning and 65% from end (end is usually more important) # This split ensures we keep more context from the end of conversations @@ -804,7 +805,7 @@ def _get_proxy_server_request_for_spend_logs_payload( perform_redaction(model_call_details=_request_body, result=None) _request_body = _sanitize_request_body_for_spend_logs_payload(_request_body) - _request_body_json_str = json.dumps(_request_body, default=str) + _request_body_json_str = safe_dumps(_request_body) if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str: verbose_proxy_logger.info( "Spend Log: request body was truncated before storing in DB. %s",