diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index d4162369a35..0d281961774 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -940,20 +940,11 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac 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 + from litellm.litellm_core_utils.redact_messages import redacted_standard_logging_payload - 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 8c77ef32cfb..d7d1d9128d1 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -172,6 +172,18 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str): output_item["input"] = redacted_str +def _redacted_responses_api_response(response: Mapping[str, object]) -> dict[str, object]: + output: Final = copy.deepcopy(response.get("output")) + if isinstance(output, list): + _redact_responses_api_output_dict(output, REDACTED_BY_LITELLM) + return { + **response, + "output": output, + **({"instructions": REDACTED_BY_LITELLM} if response.get("instructions") is not None else {}), + **({"reasoning": None} if response.get("reasoning") is not None else {}), + } + + def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]: """ Return a copy of a ``StandardLoggingPayload`` with its messages and response redacted. @@ -193,10 +205,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 isinstance(response.get("output"), list): - _redact_responses_api_output_dict(response["output"], redacted_str) - redact_vertex_ai_metadata_from_logged_object(response) + standard_logging_object["response"] = _redacted_responses_api_response(response) + redact_vertex_ai_metadata_from_logged_object(standard_logging_object["response"]) elif isinstance(response, dict) and "choices" in response: # ModelResponse dict format - redact content in choices if isinstance(response.get("choices"), list): @@ -309,9 +319,10 @@ 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 isinstance(_result.get("output"), list): - _redact_responses_api_output_dict(_result["output"], REDACTED_BY_LITELLM) + return _redacted_responses_api_response(_result) elif isinstance(_result, litellm.ResponsesAPIResponse): + if _result.instructions is not None: + _result.instructions = REDACTED_BY_LITELLM if hasattr(_result, "output"): _redact_responses_api_output(_result.output) # Redact reasoning field in ResponsesAPIResponse diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 76d037ce760..07445f38b95 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -6,6 +6,8 @@ but litellm_params["litellm_metadata"] is None. """ import threading +import copy +import json from typing import Final from types import SimpleNamespace @@ -23,6 +25,58 @@ from litellm.litellm_core_utils.redact_messages import ( from litellm.responses.main import mock_responses_api_response +@pytest.mark.parametrize("surface", ("typed", "dict", "standard", "callback")) +def test_responses_redaction_removes_instructions_without_changing_the_response(surface: str) -> None: + response: Final = litellm.ResponsesAPIResponse.model_validate( + { + **mock_responses_api_response("private answer").model_dump(), + "instructions": "private system instructions", + "reasoning": {"effort": "low", "summary": "auto"}, + "output": [ + { + "type": "reasoning", + "id": "rs_test", + "summary": [{"type": "summary_text", "text": "private reasoning"}], + }, + { + "type": "function_call", + "id": "fc_test", + "call_id": "call_test", + "name": "lookup", + "arguments": "private arguments", + }, + ], + } + ) + original: Final = response.model_dump() + payload: Final = {"response": copy.deepcopy(original), "model": "test-model"} + logger: Final = CustomLogger() + logger.turn_off_message_logging = True + + surfaces: Final = { + "typed": lambda: perform_redaction({}, response).model_dump(), + "dict": lambda: perform_redaction({}, original), + "standard": lambda: redacted_standard_logging_payload(payload)["response"], + "callback": lambda: logger.redact_standard_logging_payload_from_model_call_details( + {"standard_logging_object": payload} + )["standard_logging_object"]["response"], + } + redacted: Final = surfaces[surface]() + + assert redacted == { + **original, + "instructions": "redacted-by-litellm", + "reasoning": None, + "output": [ + {**original["output"][0], "summary": [{"type": "summary_text", "text": "redacted-by-litellm"}]}, + {**original["output"][1], "arguments": "redacted-by-litellm"}, + ], + } + assert "private" not in json.dumps(redacted) + assert response.model_dump() == original + assert payload["response"] == original + + @pytest.fixture(autouse=True) def _reset_global_redaction(): """Ensure the global setting is off for every test."""