fix(logging): redact Responses instructions and callback output

This commit is contained in:
Emerson Gomes 2026-09-26 10:38:01 -05:00
parent 115668f43e
commit 0036df1213
No known key found for this signature in database
GPG key ID: D3DF28AB5D1B5E17
3 changed files with 75 additions and 19 deletions

View file

@ -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))])

View file

@ -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

View file

@ -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."""