diff --git a/litellm/_logging.py b/litellm/_logging.py index 87d533b23fb..bcf77d65d96 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -90,7 +90,9 @@ def _is_redacted(record: logging.LogRecord) -> bool: def _redact_extra_value(key: str, value: object) -> object: try: - return json.loads(safe_dumps({key: value}, value_transform=_redact_structured_value))[key] + rendered: Final = safe_dumps({key: value}) + scrubbed: Final = safe_dumps({key: value}, value_transform=_redact_structured_value) + return value if scrubbed == rendered else json.loads(scrubbed)[key] except (TypeError, ValueError, KeyError): return _redact_string(str(value)) diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index 12754ee4f27..43be8701b49 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -1,6 +1,7 @@ import ast import asyncio import base64 +import dataclasses import json import logging import re @@ -1024,6 +1025,54 @@ def test_unserializable_extra_never_breaks_the_filter(monkeypatch, extra): assert "payload" in rendered +@dataclasses.dataclass(frozen=True, slots=True) +class _RequestExtra: + model: str + attempt: int + + +@pytest.mark.parametrize( + "extra", + ( + ("gpt-4o", 2), + {"gpt-4o", "gpt-4o-mini"}, + {"models": ("gpt-4o", "gpt-4o-mini")}, + _RequestExtra(model="gpt-4o", attempt=2), + ), + ids=("tuple", "set", "nested_tuple", "dataclass"), +) +def test_secret_free_extra_keeps_its_original_object(monkeypatch, extra): + """A host application's own handler on a litellm logger reads extras by type, so a + container that carried no secret must reach it untouched, not as its JSON shape.""" + 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 is extra + assert "payload" in json.loads(JsonFormatter().format(record)) + + +@pytest.mark.parametrize( + "extra", + (("gpt-4o", "sk-1234567890abcdefghij"), {"gpt-4o", "sk-1234567890abcdefghij"}), + ids=("tuple", "set"), +) +def test_extra_that_carried_a_secret_comes_back_scrubbed(monkeypatch, extra): + monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True) + record = _make_record(logging.WARNING, "request sent") + record.payload = extra + + assert SecretRedactionFilter().filter(record) is True + rendered = JsonFormatter().format(record) + + assert isinstance(record.payload, list) + assert sorted(record.payload) == ["REDACTED", "gpt-4o"] + assert "sk-1234567890abcdefghij" not in rendered + assert "REDACTED" in rendered + + def test_unscrubbed_record_is_still_redacted_by_the_formatter(monkeypatch): """Records that never met SecretRedactionFilter (uvicorn's, in JSON mode) keep their formatter-side redaction."""