From e505f4a4293fdfae0f2a7160da11889bee91a06f Mon Sep 17 00:00:00 2001 From: panjianxing <3207880464@qq.com> Date: Sat, 10 Oct 2026 16:01:35 +0800 Subject: [PATCH 1/3] fix(logging): redact Responses callback content --- litellm/integrations/custom_logger.py | 23 +++++---------- litellm/litellm_core_utils/redact_messages.py | 9 +++++- tests/unit/integrations/test_custom_logger.py | 28 +++++++++++++++++++ .../test_redact_messages.py | 16 ++++++++++- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 45fce665bf0..de227f034bd 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -910,7 +910,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac import litellm from litellm import Choices, Message, ModelResponse from litellm.litellm_core_utils.classifier_logging import CLASSIFIER_AUDIT_FIELDS - from litellm.litellm_core_utils.redact_messages import redacted_litellm_params + from litellm.litellm_core_utils.redact_messages import ( + redacted_litellm_params, + redacted_standard_logging_payload, + ) turn_off_message_logging: Final[bool] = getattr(self, "turn_off_message_logging", False) excluded_fields: Final[list[str] | None] = getattr(litellm, "standard_logging_payload_excluded_fields", None) @@ -945,22 +948,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac if "response" not in (excluded_fields or ()) and standard_logging_object_copy.get("response") is not None: response: Final = standard_logging_object_copy["response"] - # Check if this is a ResponsesAPIResponse (has "output" field) if isinstance(response, dict) and "output" in response: - # Make a copy to avoid modifying the original - from copy import deepcopy - - response_copy: Final = deepcopy(response) - # Redact content in output array - if isinstance(response_copy.get("output"), list): - for output_item in response_copy["output"]: - if isinstance(output_item, dict) and "content" in output_item: - if isinstance(output_item["content"], list): - # Redact text in content items - for content_item in output_item["content"]: - if isinstance(content_item, dict) and "text" in content_item: - content_item["text"] = redacted_str - standard_logging_object_copy["response"] = response_copy + standard_logging_object_copy["response"] = redacted_standard_logging_payload( + {"response": response} + )["response"] else: # Standard ModelResponse format model_response: Final = ModelResponse(choices=[Choices(message=Message(content=redacted_str))]) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 7c6c39abb76..fadb5ded5c9 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -79,6 +79,8 @@ def _redact_streaming_response(streaming_response): redact_vertex_ai_metadata_from_logged_object(streaming_response) elif hasattr(streaming_response, "output"): _redact_responses_api_output(streaming_response.output) + if getattr(streaming_response, "instructions", None) is not None: + streaming_response.instructions = REDACTED_BY_LITELLM if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None: streaming_response.reasoning = None @@ -195,7 +197,8 @@ def _redact_standard_logging_object(payload: Mapping[str, object]) -> dict[str, response: Final = 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 response.get("instructions") is not None: + response["instructions"] = redacted_str if isinstance(response.get("output"), list): _redact_responses_api_output_dict(response["output"], redacted_str) redact_vertex_ai_metadata_from_logged_object(response) @@ -330,11 +333,15 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons _redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM) redact_vertex_ai_metadata_from_logged_object(_result) elif isinstance(_result, dict) and "output" in _result: + if _result.get("instructions") is not None: + _result["instructions"] = REDACTED_BY_LITELLM if isinstance(_result.get("output"), list): _redact_responses_api_output_dict(_result["output"], REDACTED_BY_LITELLM) elif isinstance(_result, litellm.ResponsesAPIResponse): if hasattr(_result, "output"): _redact_responses_api_output(_result.output) + if _result.instructions is not None: + _result.instructions = REDACTED_BY_LITELLM # Redact reasoning field in ResponsesAPIResponse if hasattr(_result, "reasoning") and _result.reasoning is not None: _result.reasoning = None diff --git a/tests/unit/integrations/test_custom_logger.py b/tests/unit/integrations/test_custom_logger.py index 0f1d5c79bcc..875b6a7e111 100644 --- a/tests/unit/integrations/test_custom_logger.py +++ b/tests/unit/integrations/test_custom_logger.py @@ -104,6 +104,34 @@ def test_get_callback_env_vars(): assert none_env_vars == [] +def test_responses_redaction_covers_instructions_reasoning_and_tool_arguments() -> None: + response: Final = { + "instructions": "INSTRUCTION_CANARY", + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "ANSWER_CANARY"}]}, + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "REASONING_CANARY"}]}, + {"type": "function_call", "name": "example_tool", "arguments": '{"value":"ARGUMENT_CANARY"}'}, + ], + } + details: Final = {"standard_logging_object": {"response": response}} + logger: Final = CustomLogger(turn_off_message_logging=True) + + stored: Final = logger.redact_standard_logging_payload_from_model_call_details(details)["standard_logging_object"] + + assert stored["response"] == { + "instructions": "redacted-by-litellm", + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "redacted-by-litellm"}]}, + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "redacted-by-litellm"}]}, + {"type": "function_call", "name": "example_tool", "arguments": "redacted-by-litellm"}, + ], + } + assert details["standard_logging_object"]["response"] is response + assert response["instructions"] == "INSTRUCTION_CANARY" + assert response["output"][1]["summary"][0]["text"] == "REASONING_CANARY" + assert response["output"][2]["arguments"] == '{"value":"ARGUMENT_CANARY"}' + + class TestStandardLoggingPayloadExcludedFields: """Test suite for standard_logging_payload_excluded_fields feature.""" diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 6fbcba170f1..8f4f94efbb4 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -203,6 +203,7 @@ class TestPerformRedaction: "standard_logging_object": { "messages": [{"role": "user", "content": "sensitive input"}], "response": { + "instructions": "sensitive instructions", "output": [ {"text": "top-level text"}, {"content": [{"text": "nested text"}]}, @@ -213,6 +214,7 @@ class TestPerformRedaction: }, } result = { + "instructions": "sensitive result instructions", "output": [ {"text": "top-level result"}, {"content": [{"text": "nested result"}]}, @@ -228,16 +230,19 @@ class TestPerformRedaction: assert details["input"] == "" logged_response = details["standard_logging_object"]["response"] + assert logged_response["instructions"] == "redacted-by-litellm" assert logged_response["usage"] == {"total_tokens": 1} assert logged_response["output"][0]["text"] == "redacted-by-litellm" assert logged_response["output"][1]["content"][0]["text"] == ("redacted-by-litellm") assert logged_response["output"][2]["summary"][0]["text"] == ("redacted-by-litellm") assert redacted["usage"] == {"total_tokens": 1} + assert redacted["instructions"] == "redacted-by-litellm" assert redacted["output"][0]["text"] == "redacted-by-litellm" assert redacted["output"][1]["content"][0]["text"] == "redacted-by-litellm" assert redacted["output"][2]["summary"][0]["text"] == "redacted-by-litellm" assert result["output"][0]["text"] == "top-level result" + assert result["instructions"] == "sensitive result instructions" def test_redacts_model_response_dict_choices(self): result = { @@ -692,12 +697,21 @@ class TestPerformRedaction: assert redacted["output"][1]["content"][0]["text"] == "redacted-by-litellm" def test_redacts_responses_api_response_object(self): - response = mock_responses_api_response("sensitive output") + response = mock_responses_api_response("sensitive output").model_copy( + update={"instructions": "sensitive instructions"} + ) redacted = perform_redaction({}, response) assert redacted.output[0].content[0].text == "redacted-by-litellm" + assert redacted.instructions == "redacted-by-litellm" assert response.output[0].content[0].text == "sensitive output" + assert response.instructions == "sensitive instructions" + + streamed: Final = response.model_copy(deep=True) + perform_redaction({"stream": True, "complete_streaming_response": streamed}, result=None) + assert streamed.instructions == "redacted-by-litellm" + assert response.instructions == "sensitive instructions" def test_redacts_vertex_provider_metadata_in_standard_logging_response(self): details = { From 5513a21ca665eec14dc9c1d44f98c128e4c6b30a Mon Sep 17 00:00:00 2001 From: panjianxing <3207880464@qq.com> Date: Sat, 10 Oct 2026 16:11:50 +0800 Subject: [PATCH 2/3] chore(logging): explain in-place stream redaction --- litellm/litellm_core_utils/redact_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index fadb5ded5c9..6dd72c348ec 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -80,7 +80,7 @@ def _redact_streaming_response(streaming_response): elif hasattr(streaming_response, "output"): _redact_responses_api_output(streaming_response.output) if getattr(streaming_response, "instructions", None) is not None: - streaming_response.instructions = REDACTED_BY_LITELLM + streaming_response.instructions = REDACTED_BY_LITELLM # rebind-ok: redact the logged stream in place if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None: streaming_response.reasoning = None From 3697023a0290641d095b7099b3595001ed624b2b Mon Sep 17 00:00:00 2001 From: panjianxing <3207880464@qq.com> Date: Sat, 10 Oct 2026 16:28:46 +0800 Subject: [PATCH 3/3] fix(logging): type response redaction inputs --- litellm/integrations/custom_logger.py | 8 +++++--- litellm/litellm_core_utils/redact_messages.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index de227f034bd..7f85719aef6 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -949,9 +949,11 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac if "response" not in (excluded_fields or ()) and standard_logging_object_copy.get("response") is not None: response: Final = standard_logging_object_copy["response"] if isinstance(response, dict) and "output" in response: - standard_logging_object_copy["response"] = redacted_standard_logging_payload( - {"response": response} - )["response"] + redaction_payload: Final[dict[str, object]] = {} # mutable-ok: populate the typed wrapper once + redaction_payload["response"] = response + standard_logging_object_copy["response"] = redacted_standard_logging_payload(redaction_payload)[ + "response" + ] else: # Standard ModelResponse format model_response: Final = ModelResponse(choices=[Choices(message=Message(content=redacted_str))]) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 6dd72c348ec..cc4a2517856 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -197,7 +197,7 @@ def _redact_standard_logging_object(payload: Mapping[str, object]) -> dict[str, response: Final = standard_logging_object.get("response") if response is not None: if isinstance(response, dict) and "output" in response: - if response.get("instructions") is not None: + if "instructions" in response and response["instructions"] is not None: response["instructions"] = redacted_str if isinstance(response.get("output"), list): _redact_responses_api_output_dict(response["output"], redacted_str) @@ -333,14 +333,14 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons _redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM) redact_vertex_ai_metadata_from_logged_object(_result) elif isinstance(_result, dict) and "output" in _result: - if _result.get("instructions") is not None: + if "instructions" in _result and _result["instructions"] is not None: _result["instructions"] = REDACTED_BY_LITELLM if isinstance(_result.get("output"), list): _redact_responses_api_output_dict(_result["output"], REDACTED_BY_LITELLM) elif isinstance(_result, litellm.ResponsesAPIResponse): if hasattr(_result, "output"): _redact_responses_api_output(_result.output) - if _result.instructions is not None: + if getattr(_result, "instructions", None) is not None: _result.instructions = REDACTED_BY_LITELLM # Redact reasoning field in ResponsesAPIResponse if hasattr(_result, "reasoning") and _result.reasoning is not None: