fix(logging): return redacted Responses output without mutation

This commit is contained in:
Emerson Gomes 2026-09-26 12:03:15 -05:00
parent f022486034
commit 73003559e6
No known key found for this signature in database
GPG key ID: D3DF28AB5D1B5E17
2 changed files with 49 additions and 33 deletions

View file

@ -145,40 +145,50 @@ def _redact_responses_api_output(output_items):
output_item.input = REDACTED_BY_LITELLM
def _redact_responses_api_output_dict(output_items, redacted_str: str):
"""Helper to redact ResponsesAPIResponse output items in dict form."""
for output_item in output_items:
if not isinstance(output_item, dict):
continue
def _redacted_responses_api_output_part(part: object, fields: tuple[str, ...], redacted_str: str) -> object:
if not isinstance(part, dict):
return part
return {key: redacted_str if key in fields and value is not None else value for key, value in part.items()}
if output_item.get("text") is not None:
output_item["text"] = redacted_str
if isinstance(output_item.get("content"), list):
for content_item in output_item["content"]:
if isinstance(content_item, dict) and content_item.get("text") is not None:
content_item["text"] = redacted_str
if isinstance(content_item, dict) and content_item.get("refusal") is not None:
content_item["refusal"] = redacted_str
def _redacted_responses_api_output_item(item: object, redacted_str: str) -> object:
if not isinstance(item, dict):
return item
content: Final = item.get("content")
summary: Final = item.get("summary")
return {
**item,
**({"text": redacted_str} if item.get("text") is not None else {}),
**(
{
"content": [
_redacted_responses_api_output_part(part, ("text", "refusal"), redacted_str) for part in content
]
}
if isinstance(content, list)
else {}
),
**(
{"summary": [_redacted_responses_api_output_part(part, ("text",), redacted_str) for part in summary]}
if item.get("type") == "reasoning" and isinstance(summary, list)
else {}
),
**({"arguments": redacted_str} if item.get("type") == "function_call" and "arguments" in item else {}),
**({"input": redacted_str} if item.get("type") == "custom_tool_call" and "input" in item else {}),
}
if output_item.get("type") == "reasoning" and isinstance(output_item.get("summary"), list):
for summary_item in output_item["summary"]:
if isinstance(summary_item, dict) and summary_item.get("text") is not None:
summary_item["text"] = redacted_str
if output_item.get("type") == "function_call" and "arguments" in output_item:
output_item["arguments"] = redacted_str
if output_item.get("type") == "custom_tool_call" and "input" in output_item:
output_item["input"] = redacted_str
def _redact_responses_api_output_dict(output_items: list[object], redacted_str: str) -> list[object]:
return [_redacted_responses_api_output_item(item, redacted_str) for item in output_items]
def _redacted_responses_api_response(response: Mapping[str, object]) -> dict[str, object]:
output: Final = response.get("output")
if isinstance(output, list):
_redact_responses_api_output_dict(output, REDACTED_BY_LITELLM)
return {
**response,
"output": output,
"output": _redact_responses_api_output_dict(output, REDACTED_BY_LITELLM)
if isinstance(output, list)
else output,
**({"instructions": REDACTED_BY_LITELLM} if response.get("instructions") is not None else {}),
**({"reasoning": None} if response.get("reasoning") is not None else {}),
}
@ -321,13 +331,14 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
elif isinstance(_result, dict) and "output" in _result:
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
if hasattr(_result, "reasoning") and _result.reasoning is not None:
_result.reasoning = None
return _result.model_copy(
update={"instructions": REDACTED_BY_LITELLM} if _result.instructions is not None else {}
)
elif isinstance(_result, litellm.EmbeddingResponse):
if hasattr(_result, "data") and _result.data is not None:
_result.data = []

View file

@ -17,6 +17,7 @@ import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.redact_messages import (
_redact_responses_api_output,
_redacted_responses_api_response,
perform_redaction,
redact_streaming_responses_for_custom_logger,
redacted_standard_logging_payload,
@ -25,7 +26,7 @@ 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"))
@pytest.mark.parametrize("surface", ("typed", "dict", "standard", "callback", "helper"))
def test_responses_redaction_removes_instructions_without_changing_the_response(surface: str) -> None:
response: Final = litellm.ResponsesAPIResponse.model_validate(
{
@ -56,6 +57,7 @@ def test_responses_redaction_removes_instructions_without_changing_the_response(
surfaces: Final = {
"typed": lambda: perform_redaction({}, response).model_dump(),
"dict": lambda: perform_redaction({}, original),
"helper": lambda: _redacted_responses_api_response(original),
"standard": lambda: redacted_standard_logging_payload(payload)["response"],
"callback": lambda: logger.redact_standard_logging_payload_from_model_call_details(
{"standard_logging_object": payload}
@ -251,7 +253,7 @@ class TestPerformRedaction:
result = {
"output": [
{"text": "top-level result"},
{"content": [{"text": "nested result"}]},
{"content": [{"text": "nested result"}, "non-dict content item"]},
{"type": "reasoning", "summary": [{"text": "reasoning result"}]},
],
"usage": {"total_tokens": 1},
@ -278,6 +280,7 @@ class TestPerformRedaction:
assert redacted["usage"] == {"total_tokens": 1}
assert redacted["output"][0]["text"] == "redacted-by-litellm"
assert redacted["output"][1]["content"][0]["text"] == "redacted-by-litellm"
assert redacted["output"][1]["content"][1] == "non-dict content item"
assert redacted["output"][2]["summary"][0]["text"] == "redacted-by-litellm"
assert result["output"][0]["text"] == "top-level result"
@ -715,12 +718,14 @@ class TestPerformRedaction:
none_dict = {"type": "output_text", "text": None, "content": [{"text": None}]}
real_dict = {"type": "output_text", "text": "real answer", "content": [{"text": "real part"}]}
_redact_responses_api_output_dict([none_dict, real_dict], "redacted-by-litellm")
redacted: Final = _redact_responses_api_output_dict([none_dict, real_dict], "redacted-by-litellm")
assert none_dict["text"] is None
assert none_dict["content"][0]["text"] is None
assert real_dict["text"] == "redacted-by-litellm"
assert real_dict["content"][0]["text"] == "redacted-by-litellm"
assert redacted == [
{"type": "output_text", "text": None, "content": [{"text": None}]},
{"type": "output_text", "text": "redacted-by-litellm", "content": [{"text": "redacted-by-litellm"}]},
]
assert none_dict == {"type": "output_text", "text": None, "content": [{"text": None}]}
assert real_dict == {"type": "output_text", "text": "real answer", "content": [{"text": "real part"}]}
def test_skips_non_dict_response_output_items(self):
result = {