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
This commit is contained in:
xy.kong 2026-03-22 02:39:51 +08:00
parent d7c419bfee
commit 220f39be5a
2 changed files with 24 additions and 5 deletions

View file

@ -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"

View file

@ -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",