mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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
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:
parent
d5570d04d9
commit
2f186055e6
4 changed files with 72 additions and 31 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]}}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue