From aaef0ce3eaef1091715ee5bf1ca776a995d49301 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 22 Jan 2026 12:52:12 -0800 Subject: [PATCH 1/6] perf: skip redundant redaction when global redaction enabled When turn_off_message_logging is enabled globally, skip per-callback redaction functions that would re-process already-redacted data. 17% faster async_success_handler when global redaction is ON. --- litellm/integrations/custom_logger.py | 8 +++++++- litellm/litellm_core_utils/litellm_logging.py | 15 ++++++++++----- litellm/litellm_core_utils/redact_messages.py | 9 ++++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index c244363e389..59e3b7a5007 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -771,7 +771,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac return OLD_LITELLM_METADATA_FIELD def redact_standard_logging_payload_from_model_call_details( - self, model_call_details: Dict + self, + model_call_details: Dict, + global_redaction_applied: bool = False, ) -> Dict: """ Redacts or excludes fields from StandardLoggingPayload before callbacks receive it. @@ -784,6 +786,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac This is useful for logging payloads that contain sensitive information. """ + # skip redundant redaction if global redaction was already applied + if global_redaction_applied: + return model_call_details + import litellm from copy import copy diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index bdbbc7579b7..31db3fb42eb 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -68,6 +68,7 @@ from litellm.litellm_core_utils.model_param_helper import ModelParamHelper from litellm.litellm_core_utils.redact_messages import ( redact_message_input_output_from_custom_logger, redact_message_input_output_from_logging, + should_redact_message_logging, ) from litellm.llms.base_llm.ocr.transformation import OCRResponse from litellm.llms.base_llm.search.transformation import SearchResponse @@ -2483,10 +2484,10 @@ class Logging(LiteLLMLoggingBaseClass): global_callbacks=litellm._async_success_callback, ) + _model_call_details = self.model_call_details if hasattr(self, "model_call_details") else {} + global_redaction_applied = should_redact_message_logging(_model_call_details) result = redact_message_input_output_from_logging( - model_call_details=( - self.model_call_details if hasattr(self, "model_call_details") else {} - ), + model_call_details=_model_call_details, result=result, ) @@ -2512,7 +2513,10 @@ class Logging(LiteLLMLoggingBaseClass): ) elif isinstance(callback, CustomLogger): result = redact_message_input_output_from_custom_logger( - result=result, litellm_logging_obj=self, custom_logger=callback + result=result, + litellm_logging_obj=self, + custom_logger=callback, + global_redaction_applied=global_redaction_applied, ) self.model_call_details, result = await callback.async_logging_hook( kwargs=self.model_call_details, @@ -2567,7 +2571,8 @@ class Logging(LiteLLMLoggingBaseClass): ################################## # call redaction hook for custom logger model_call_details = callback.redact_standard_logging_payload_from_model_call_details( - model_call_details=model_call_details + model_call_details=model_call_details, + global_redaction_applied=global_redaction_applied, ) ################################## if self.stream is True: diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 5d6d1fbc1c5..e9d9bcf8096 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -30,8 +30,15 @@ else: def redact_message_input_output_from_custom_logger( - litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger + litellm_logging_obj: LiteLLMLoggingObject, + result, + custom_logger: CustomLogger, + global_redaction_applied: bool = False, ): + # skip redundant redaction if global redaction was already applied + if global_redaction_applied: + return result + if ( hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True From d527a042ae94ea9bec8cf5b2c1644eac8fb281ee Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 22 Jan 2026 15:04:36 -0800 Subject: [PATCH 2/6] perf: skip redundant redaction + avoid double check - Add global_redaction_applied flag to skip per-callback redaction - Add should_redact param to avoid calling should_redact_message_logging twice - Add tests for both flag=True (skip) and flag=False (proceed) cases --- litellm/litellm_core_utils/litellm_logging.py | 1 + litellm/litellm_core_utils/redact_messages.py | 9 ++- .../test_litellm_logging.py | 74 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 31db3fb42eb..c33af6758d9 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -2489,6 +2489,7 @@ class Logging(LiteLLMLoggingBaseClass): result = redact_message_input_output_from_logging( model_call_details=_model_call_details, result=result, + should_redact=global_redaction_applied, ) ## LOGGING HOOK ## diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index e9d9bcf8096..c5ece971cce 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -189,13 +189,18 @@ def should_redact_message_logging(model_call_details: dict) -> bool: def redact_message_input_output_from_logging( - model_call_details: dict, result, input: Optional[Any] = None + model_call_details: dict, + result, + input: Optional[Any] = None, + should_redact: Optional[bool] = None, ) -> Any: """ Removes messages, prompts, input, response from logging. This modifies the data in-place only redacts when litellm.turn_off_message_logging == True """ - if should_redact_message_logging(model_call_details): + if should_redact is None: + should_redact = should_redact_message_logging(model_call_details) + if should_redact: return perform_redaction(model_call_details, result) return result 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 734d52918ba..34059a5243d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1355,3 +1355,77 @@ def test_get_error_information_error_code_priority(): result = StandardLoggingPayloadSetup.get_error_information(no_code_exception) assert result["error_code"] == "" assert result["error_class"] == "NoCodeException" + + +def test_global_redaction_skips_per_callback_redaction(): + """ + When global_redaction_applied=True, per-callback redaction functions should + return early without processing to avoid redundant work. + """ + from litellm.litellm_core_utils.redact_messages import ( + redact_message_input_output_from_custom_logger, + ) + from litellm.integrations.custom_logger import CustomLogger + + # Test redact_message_input_output_from_custom_logger skips when global redaction applied + custom_logger = CustomLogger() + custom_logger.message_logging = False # Would normally trigger redaction + + mock_logging_obj = MagicMock() + mock_logging_obj.model_call_details = {"messages": [{"content": "secret"}]} + + original_result = {"response": "already-redacted"} + + result = redact_message_input_output_from_custom_logger( + litellm_logging_obj=mock_logging_obj, + result=original_result, + custom_logger=custom_logger, + global_redaction_applied=True, + ) + + assert result is original_result # Should return unchanged (early return) + + # Test CustomLogger.redact_standard_logging_payload_from_model_call_details skips + custom_logger.turn_off_message_logging = True + + model_call_details = { + "messages": [{"content": "secret"}], + "standard_logging_object": {"response": "sensitive"}, + } + + result = custom_logger.redact_standard_logging_payload_from_model_call_details( + model_call_details=model_call_details, + global_redaction_applied=True, + ) + + assert result is model_call_details # Should return unchanged (early return) + + +def test_per_callback_redaction_proceeds_when_global_redaction_not_applied(): + """ + When global_redaction_applied=False, per-callback redaction should still + proceed normally if the callback has redaction enabled. + """ + from litellm.litellm_core_utils.redact_messages import ( + redact_message_input_output_from_custom_logger, + ) + from litellm.integrations.custom_logger import CustomLogger + + # Test redact_message_input_output_from_custom_logger proceeds when global redaction NOT applied + custom_logger = CustomLogger() + custom_logger.message_logging = False # Triggers redaction + + mock_logging_obj = MagicMock() + mock_logging_obj.model_call_details = {"messages": [{"content": "secret"}]} + + original_result = {"response": "sensitive-data"} + + result = redact_message_input_output_from_custom_logger( + litellm_logging_obj=mock_logging_obj, + result=original_result, + custom_logger=custom_logger, + global_redaction_applied=False, + ) + + # Result should be different (redacted), not the same object + assert result is not original_result From 492ad0c282ba354ff742079d8d7dd88ab250f471 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 22 Jan 2026 16:48:28 -0800 Subject: [PATCH 3/6] fix: Add safeguard to allow overridden redaction methods to run Check if redact_standard_logging_payload_from_model_call_details was overridden in a subclass before skipping redundant redaction. This ensures custom callbacks with additional redaction logic still execute. --- litellm/integrations/custom_logger.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 59e3b7a5007..e9e200ff803 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -786,9 +786,16 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac This is useful for logging payloads that contain sensitive information. """ - # skip redundant redaction if global redaction was already applied + # Only skip if global redaction applied AND method is not overridden if global_redaction_applied: - return model_call_details + # Check if this method was overridden in a subclass + method_name = "redact_standard_logging_payload_from_model_call_details" + is_overridden = method_name in type(self).__dict__ + + if not is_overridden: + # Safe to skip - using default implementation + return model_call_details + # Method was overridden - might do additional redaction, so proceed import litellm from copy import copy From 5d69c66813c62e92e4c5f3d06ab10e53dae759d8 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 27 Jan 2026 13:04:54 -0800 Subject: [PATCH 4/6] fix: Check full MRO for method override detection --- litellm/integrations/custom_logger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index e9e200ff803..83f55bed24f 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -788,9 +788,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac """ # Only skip if global redaction applied AND method is not overridden if global_redaction_applied: - # Check if this method was overridden in a subclass + # Check if method was overridden anywhere in the inheritance chain (walks full MRO) method_name = "redact_standard_logging_payload_from_model_call_details" - is_overridden = method_name in type(self).__dict__ + is_overridden = getattr(type(self), method_name) is not getattr(CustomLogger, method_name) if not is_overridden: # Safe to skip - using default implementation From 4cea5bd9fa23ee9970c0a60541850cb68d53c329 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 27 Jan 2026 13:49:10 -0800 Subject: [PATCH 5/6] Add unit test for MRO-aware method override detection Tests that the fix using `getattr(type(self), method_name) is not getattr(CustomLogger, method_name)` correctly walks the full Method Resolution Order, unlike the buggy `method_name in type(self).__dict__` which only checks the immediate class. --- .../test_litellm_logging.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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 34059a5243d..cc5bb3cf01c 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1429,3 +1429,80 @@ def test_per_callback_redaction_proceeds_when_global_redaction_not_applied(): # Result should be different (redacted), not the same object assert result is not original_result + + +def test_method_override_detection_walks_full_mro(): + """ + Test that method override detection correctly walks the full Method Resolution Order (MRO), + not just the immediate class's __dict__. + + This tests the fix for a bug where: + - `method_name in type(self).__dict__` only checks the immediate class + - `getattr(type(self), method_name) is not getattr(CustomLogger, method_name)` walks the full MRO + + The bug caused incorrect behavior when: + - ClassA(CustomLogger) overrides a method + - ClassB(ClassA) does NOT override the method + - ClassB instance should still detect the override from ClassA + """ + from litellm.integrations.custom_logger import CustomLogger + + # Case 1: Direct override - should be detected + class DirectOverrideLogger(CustomLogger): + def redact_standard_logging_payload_from_model_call_details( + self, model_call_details, global_redaction_applied=False + ): + # Custom implementation + return {"custom": "redacted"} + + direct_logger = DirectOverrideLogger() + method_name = "redact_standard_logging_payload_from_model_call_details" + + # Using the correct MRO-aware check + is_overridden_mro = getattr(type(direct_logger), method_name) is not getattr( + CustomLogger, method_name + ) + assert is_overridden_mro is True, "Direct override should be detected" + + # Case 2: Inherited override (the bug case) - should also be detected + class InheritedOverrideLogger(DirectOverrideLogger): + # Does NOT override the method - inherits from DirectOverrideLogger + pass + + inherited_logger = InheritedOverrideLogger() + + # The buggy check (only checks immediate class __dict__) would return False + buggy_check = method_name in type(inherited_logger).__dict__ + assert buggy_check is False, "Buggy check incorrectly misses inherited override" + + # The correct MRO-aware check should return True + is_overridden_mro = getattr(type(inherited_logger), method_name) is not getattr( + CustomLogger, method_name + ) + assert is_overridden_mro is True, "MRO check should detect inherited override" + + # Case 3: No override - should NOT be detected as overridden + class NoOverrideLogger(CustomLogger): + # Does NOT override the method + pass + + no_override_logger = NoOverrideLogger() + is_overridden_mro = getattr(type(no_override_logger), method_name) is not getattr( + CustomLogger, method_name + ) + assert is_overridden_mro is False, "No override should not be detected" + + # Case 4: Verify actual behavior - inherited override should NOT skip redaction + model_call_details = { + "messages": [{"content": "secret"}], + "standard_logging_object": {"response": "sensitive"}, + } + + # With global_redaction_applied=True and an inherited override, + # the method should NOT early-return (should proceed to custom implementation) + result = inherited_logger.redact_standard_logging_payload_from_model_call_details( + model_call_details=model_call_details, + global_redaction_applied=True, + ) + # DirectOverrideLogger returns {"custom": "redacted"} + assert result == {"custom": "redacted"}, "Inherited override should execute custom logic" From d84e5e381acf50489ba74ca1e463fae9d1a34132 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 7 Feb 2026 10:34:30 -0800 Subject: [PATCH 6/6] 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 c5ece971cce..4fe19f042fd 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 cc5bb3cf01c..549cc28bd9c 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