fix: redact standard_logging_object in global redaction path

perform_redaction did not redact standard_logging_object, so when
per-callback redaction was skipped (global_redaction_applied=True),
sensitive messages/responses could leak to callbacks like Langfuse
and Datadog. Extend perform_redaction to cover this field.
This commit is contained in:
Ryan Crabbe
2026-02-07 10:34:30 -08:00
parent 4cea5bd9fa
commit d84e5e381a
2 changed files with 163 additions and 0 deletions
@@ -79,6 +79,44 @@ def _redact_responses_api_output(output_items):
summary_item.text = "redacted-by-litellm"
def _redact_standard_logging_object(model_call_details: dict):
"""Redact messages and response inside standard_logging_object if present."""
standard_logging_object = model_call_details.get("standard_logging_object")
if standard_logging_object is None:
return
redacted_str = "redacted-by-litellm"
if standard_logging_object.get("messages") is not None:
standard_logging_object["messages"] = [
{"role": "user", "content": redacted_str}
]
response = standard_logging_object.get("response")
if response is not None:
if isinstance(response, dict) and "output" in response:
# ResponsesAPIResponse format - redact content in output items
if isinstance(response.get("output"), list):
for output_item in response["output"]:
if isinstance(output_item, dict) and "content" in output_item:
if isinstance(output_item["content"], list):
for content_item in output_item["content"]:
if (
isinstance(content_item, dict)
and "text" in content_item
):
content_item["text"] = redacted_str
elif isinstance(response, str):
standard_logging_object["response"] = redacted_str
else:
# Standard ModelResponse dict format
standard_logging_object["response"] = {
"choices": [
{"message": {"content": redacted_str}}
]
}
def perform_redaction(model_call_details: dict, result):
"""
Performs the actual redaction on the logging object and result.
@@ -90,6 +128,9 @@ def perform_redaction(model_call_details: dict, result):
model_call_details["prompt"] = ""
model_call_details["input"] = ""
# Redact standard_logging_object if present
_redact_standard_logging_object(model_call_details)
# Redact streaming response
if (
model_call_details.get("stream", False) is True
@@ -1401,6 +1401,128 @@ def test_global_redaction_skips_per_callback_redaction():
assert result is model_call_details # Should return unchanged (early return)
def test_perform_redaction_redacts_standard_logging_object():
"""
When perform_redaction runs (global redaction path), it should also redact
the standard_logging_object inside model_call_details.
This prevents sensitive data from leaking to callbacks (e.g. Langfuse, Datadog)
when per-callback redaction is skipped due to global_redaction_applied=True.
"""
from litellm.litellm_core_utils.redact_messages import perform_redaction
# Standard ModelResponse format
model_call_details = {
"messages": [{"role": "user", "content": "my secret prompt"}],
"prompt": "my secret prompt",
"input": "my secret input",
"standard_logging_object": {
"messages": [{"role": "user", "content": "my secret prompt"}],
"response": {
"choices": [
{"message": {"content": "sensitive response data"}}
]
},
},
}
perform_redaction(model_call_details, result=None)
slo = model_call_details["standard_logging_object"]
# Messages should be redacted
assert slo["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
# Response should be redacted
assert slo["response"]["choices"][0]["message"]["content"] == "redacted-by-litellm"
# ResponsesAPIResponse format
model_call_details_responses = {
"messages": [{"role": "user", "content": "my secret prompt"}],
"prompt": "",
"input": "",
"standard_logging_object": {
"messages": [{"role": "user", "content": "my secret prompt"}],
"response": {
"output": [
{
"content": [
{"type": "output_text", "text": "sensitive response"}
]
}
]
},
},
}
perform_redaction(model_call_details_responses, result=None)
slo = model_call_details_responses["standard_logging_object"]
assert slo["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert slo["response"]["output"][0]["content"][0]["text"] == "redacted-by-litellm"
# No standard_logging_object - should not raise
model_call_details_none = {
"messages": [{"role": "user", "content": "prompt"}],
"prompt": "",
"input": "",
}
perform_redaction(model_call_details_none, result=None) # should not raise
def test_global_redaction_covers_standard_logging_object_for_callbacks():
"""
End-to-end test: when global redaction is applied, per-callback redaction
is skipped, but standard_logging_object must still be redacted because
perform_redaction (the global path) now handles it.
"""
from litellm.litellm_core_utils.redact_messages import (
redact_message_input_output_from_custom_logger,
redact_message_input_output_from_logging,
)
from litellm.integrations.custom_logger import CustomLogger
model_call_details = {
"messages": [{"role": "user", "content": "secret prompt"}],
"prompt": "secret prompt",
"input": "secret input",
"litellm_params": {},
"standard_logging_object": {
"messages": [{"role": "user", "content": "secret prompt"}],
"response": "sensitive response string",
},
}
# Step 1: Global redaction (simulates what async_success_handler does)
redact_message_input_output_from_logging(
model_call_details=model_call_details,
result=None,
should_redact=True,
)
# Verify standard_logging_object was redacted by the global path
slo = model_call_details["standard_logging_object"]
assert slo["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert slo["response"] == "redacted-by-litellm"
# Step 2: Per-callback redaction is skipped (as expected)
custom_logger = CustomLogger()
custom_logger.message_logging = False
mock_logging_obj = MagicMock()
mock_logging_obj.model_call_details = model_call_details
result = redact_message_input_output_from_custom_logger(
litellm_logging_obj=mock_logging_obj,
result=None,
custom_logger=custom_logger,
global_redaction_applied=True,
)
# Per-callback redaction skipped, but standard_logging_object is still redacted
# from the global path above
assert slo["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert slo["response"] == "redacted-by-litellm"
def test_per_callback_redaction_proceeds_when_global_redaction_not_applied():
"""
When global_redaction_applied=False, per-callback redaction should still