fix(logging): decide keep-or-scrub for a log extra by comparing it to its scrubbed copy
Some checks are pending
ai-gateway image / ai-gateway release image (push) Waiting to run

The code-quality check refuses recursive functions and the walk that inspected
extras was one, so the filter no longer walks anything itself. safe_dumps now
builds its JSON-native structure through safe_json_structure, the filter scrubs
the extra through that, and the original object is kept only when the scrubbed
copy compares equal to it. Anything the serializer skipped (non-string keys,
nests past its depth, fields a repr hides) makes the copy differ, so the copy
wins. A host object whose equality raises, as numpy arrays and torch tensors
do, counts as changed instead of breaking the caller's log call
This commit is contained in:
mateo-berri 2026-09-16 16:29:23 -07:00
parent d5570d04d9
commit 2f186055e6
4 changed files with 72 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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