diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 154306d01b8..81cd8e57798 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -24,7 +24,7 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: return "MaxDepthExceeded" # Base-case: if it is a primitive, simply return it. if isinstance(obj, str): - return strip_null_bytes(obj) + return obj.replace("\x00", "") if "\x00" in obj else obj if isinstance(obj, (int, float, bool, type(None))): return obj # Check for circular reference. @@ -36,7 +36,8 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: result = {} for k, v in obj.items(): if isinstance(k, (str)): - result[strip_null_bytes(k)] = _serialize(v, seen, depth + 1) + clean_k = k.replace("\x00", "") if "\x00" in k else k + result[clean_k] = _serialize(v, seen, depth + 1) seen.remove(id(obj)) return result elif isinstance(obj, list): diff --git a/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py b/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py index 74574370e46..ad24105588d 100644 --- a/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py +++ b/tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py @@ -174,6 +174,30 @@ def test_null_byte_stripped_in_fallback_str(): assert json.loads(out)["obj"] == "objrepr" +def test_clean_strings_are_not_run_through_replace(): + """Regression for LIT-3910. + + safe_dumps must not call ``str.replace`` on NUL-free strings. Running the + NUL strip unconditionally on every value and dict key (the v1.89.x behavior) + added per-request serialization overhead that scaled with payload size and + showed up under ``store_prompts_in_spend_logs``. Clean strings, which are the + overwhelming majority, must be returned untouched. + """ + + class ReplaceForbidden(str): + def replace(self, *args, **kwargs): + raise AssertionError("safe_dumps ran str.replace on a NUL-free string") + + data = { + ReplaceForbidden("clean_key"): ReplaceForbidden("clean_value"), + "nested": [ReplaceForbidden("a"), {"deep": ReplaceForbidden("b")}], + } + result = json.loads(safe_dumps(data)) + assert result["clean_key"] == "clean_value" + assert result["nested"][0] == "a" + assert result["nested"][1]["deep"] == "b" + + def test_pydantic_base_model(): from pydantic import BaseModel