From d84e5e381acf50489ba74ca1e463fae9d1a34132 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 7 Feb 2026 10:34:30 -0800 Subject: [PATCH] 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. --- litellm/litellm_core_utils/redact_messages.py | 41 ++++++ .../test_litellm_logging.py | 122 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index c5ece971cc..4fe19f042f 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -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 diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index cc5bb3cf01..549cc28bd9 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -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