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