diff --git a/litellm/_logging.py b/litellm/_logging.py index 706ae0c8282..d01960ed7ac 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -1,7 +1,6 @@ import ast import contextvars import functools -import json import logging import os import re @@ -13,14 +12,13 @@ from urllib.parse import unquote import litellm from litellm.constants import ( - DEFAULT_MAX_RECURSE_DEPTH, LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_STDOUT_SAFEGUARD_NOTE, MAX_BASE64_LENGTH_STDOUT_LOG, MAX_STRING_LENGTH_STDOUT_LOG, ) from litellm.litellm_core_utils.env_utils import get_env_int -from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, safe_json_structure from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.secret_redaction import ( redact_internal_details, @@ -89,27 +87,19 @@ def _is_redacted(record: logging.LogRecord) -> bool: return getattr(record, _REDACTED_RECORD_ATTR, False) is True -def _is_secret_free(key: str | None, value: object, depth: int) -> bool: - if depth > DEFAULT_MAX_RECURSE_DEPTH: +def _scrubbing_changed_nothing(scrubbed: object, original: object) -> bool: + try: + return bool(scrubbed == original) + except Exception: return False - if isinstance(value, str): - return _redact_structured_value(key, value) == value - if isinstance(value, _UNREDACTED_SCALAR_TYPES): - return True - if isinstance(value, dict): - return all(isinstance(k, str) and _is_secret_free(k, v, depth + 1) for k, v in value.items()) - if isinstance(value, (list, tuple)): - return all(_is_secret_free(key, item, depth + 1) for item in value) - return False def _redact_extra_value(key: str, value: object) -> object: - if _is_secret_free(key, value, 1): - return value try: - return json.loads(safe_dumps({key: value}, value_transform=_redact_structured_value))[key] - except (TypeError, ValueError, KeyError): + scrubbed: Final = safe_json_structure(value, value_transform=_redact_structured_value, key=key) + except (TypeError, ValueError): return _redact_string(str(value)) + return value if _scrubbing_changed_nothing(scrubbed, value) else scrubbed def redact_secrets(value: str) -> str: diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 5b99e8cba98..7cc47b2d6c3 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -12,19 +12,21 @@ def strip_null_bytes(value: str) -> str: return value.replace("\x00", "") -def safe_dumps( - data: Any, +def safe_json_structure( + data: object, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH, value_transform: Callable[[str | None, str], str] | None = None, -) -> str: + key: str | None = None, +) -> object: """ - Recursively serialize data while detecting circular references. + Rebuild data out of JSON-native pieces while detecting circular references. If a circular reference is detected then a marker string is returned. NUL bytes are stripped from strings to prevent PostgreSQL 22P05 errors. value_transform, when given, is applied to every string leaf (and to the str() fallback for non-serializable objects) with the mapping key the leaf was reached under, so callers can rewrite values without touching structure. + key is the mapping key data itself was reached under, when the caller has one. """ def _transform(key: str | None, value: str) -> str: @@ -77,5 +79,13 @@ def safe_dumps( except Exception: return "Unserializable Object" - safe_data: Final = _serialize(data, set(), 0) - return json.dumps(safe_data, default=str) + return _serialize(data, set(), 0, key) + + +def safe_dumps( + data: Any, + max_depth: int = DEFAULT_MAX_RECURSE_DEPTH, + value_transform: Callable[[str | None, str], str] | None = None, +) -> str: + """Serialize data to JSON text through safe_json_structure.""" + return json.dumps(safe_json_structure(data, max_depth, value_transform), default=str) 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 30385ba758d..1f2664f33cb 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 @@ -3,7 +3,7 @@ import json import pytest -from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, safe_json_structure, strip_null_bytes def test_primitive_types(): @@ -225,3 +225,14 @@ def test_pydantic_base_model(): assert len(result["healthy_endpoints"]) == 2 assert result["healthy_endpoints"][0]["name"] == "test" assert result["healthy_endpoints"][1] == {"value": 1, "label": "one"} + + +def test_safe_json_structure_keeps_tuples_and_drops_non_string_keys(): + data = {"models": ("a", "b"), "tags": {"y", "x"}, 1: "dropped", "nested": {"deep": ("c",)}} + + structure = safe_json_structure(data, value_transform=lambda key, value: value.upper()) + + assert isinstance(structure, dict) + assert structure == {"models": ("A", "B"), "tags": ["X", "Y"], "nested": {"deep": ("C",)}} + assert type(structure["models"]) is tuple + assert json.loads(safe_dumps(data)) == {"models": ["a", "b"], "tags": ["x", "y"], "nested": {"deep": ["c"]}} diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index 884c120f5c9..18fb86e5a2a 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -1061,11 +1061,15 @@ def test_secret_free_extra_keeps_its_original_object(monkeypatch, extra): @pytest.mark.parametrize( - "extra", - (("gpt-4o", "sk-1234567890abcdefghij"), {"gpt-4o", "sk-1234567890abcdefghij"}), - ids=("tuple", "set"), + "extra,scrubbed", + ( + (("gpt-4o", "sk-1234567890abcdefghij"), ("gpt-4o", "REDACTED")), + ({"gpt-4o", "sk-1234567890abcdefghij"}, ["REDACTED", "gpt-4o"]), + ({"model": "gpt-4o", "key": "sk-1234567890abcdefghij"}, {"model": "gpt-4o", "key": "REDACTED"}), + ), + ids=("tuple", "set", "dict"), ) -def test_extra_that_carried_a_secret_comes_back_scrubbed(monkeypatch, extra): +def test_extra_that_carried_a_secret_comes_back_scrubbed(monkeypatch, extra, scrubbed): monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True) record = _make_record(logging.WARNING, "request sent") record.payload = extra @@ -1073,12 +1077,38 @@ def test_extra_that_carried_a_secret_comes_back_scrubbed(monkeypatch, extra): assert SecretRedactionFilter().filter(record) is True rendered = JsonFormatter().format(record) - assert isinstance(record.payload, list) - assert sorted(record.payload) == ["REDACTED", "gpt-4o"] + assert record.payload == scrubbed + assert type(record.payload) is type(scrubbed) assert "sk-1234567890abcdefghij" not in rendered assert "REDACTED" in rendered +class _AmbiguousArray: + def __eq__(self, other: object) -> bool: + raise ValueError("The truth value of an array with more than one element is ambiguous") + + def __repr__(self) -> str: + return "array([1, 2])" + + +@pytest.mark.parametrize( + "extra,scrubbed", + ((_AmbiguousArray(), "array([1, 2])"), ({"weights": _AmbiguousArray()}, {"weights": "array([1, 2])"})), + ids=("top_level", "nested"), +) +def test_extra_whose_equality_raises_still_comes_back_scrubbed(monkeypatch, extra, scrubbed): + """numpy arrays and torch tensors raise when compared for truth, so the keep-or-scrub + decision must fall on the scrubbed copy instead of breaking the caller's log call.""" + monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True) + record = _make_record(logging.WARNING, "request sent") + record.payload = extra + + assert SecretRedactionFilter().filter(record) is True + + assert record.payload == scrubbed + assert json.loads(JsonFormatter().format(record))["payload"] == scrubbed + + @pytest.mark.parametrize( "extra", (