fix(logging): redact provider_specific_fields and request-body snapshots when message logging is off

This commit is contained in:
michelligabriele 2026-05-22 16:07:27 +02:00
parent d04373f4ce
commit e2d2b60a3b
No known key found for this signature in database
2 changed files with 291 additions and 0 deletions

View file

@ -49,12 +49,42 @@ def _redact_choice_content(choice):
choice.message.reasoning_content = "redacted-by-litellm"
if hasattr(choice.message, "thinking_blocks"):
choice.message.thinking_blocks = None
_redact_provider_specific_fields(
getattr(choice.message, "provider_specific_fields", None)
)
elif isinstance(choice, litellm.utils.StreamingChoices):
choice.delta.content = "redacted-by-litellm"
if hasattr(choice.delta, "reasoning_content"):
choice.delta.reasoning_content = "redacted-by-litellm"
if hasattr(choice.delta, "thinking_blocks"):
choice.delta.thinking_blocks = None
_redact_provider_specific_fields(
getattr(choice.delta, "provider_specific_fields", None)
)
# Keys inside Message.provider_specific_fields that duplicate reasoning
# content already covered by the flat-field scrub. Anthropic populates
# "thinking_blocks" / "reasoning_content" (the latter also carries the
# raw "signature" blob); Bedrock converse populates "reasoningContentBlocks".
_PROVIDER_SPECIFIC_REASONING_KEYS = (
"reasoning_content",
"thinking_blocks",
"reasoningContentBlocks",
)
def _redact_provider_specific_fields(psf, redacted_str: str = "redacted-by-litellm"):
"""Scrub reasoning-content duplicates inside Message.provider_specific_fields."""
if not isinstance(psf, dict):
return
for key in _PROVIDER_SPECIFIC_REASONING_KEYS:
if key not in psf:
continue
if key == "reasoning_content":
psf[key] = redacted_str
else:
psf[key] = None
def _redact_responses_api_output(output_items):
@ -130,6 +160,61 @@ def _redact_standard_logging_object(model_call_details: dict):
standard_logging_object["response"] = {"text": redacted_str}
def _redact_proxy_server_request_body(model_call_details: dict):
"""Redact the input-bearing keys inside the proxy's body snapshot.
``litellm_params["proxy_server_request"]["body"]`` is a separate copy of
the request payload built during proxy pre-call (see
``litellm_pre_call_utils.add_litellm_data_to_request``). The flat-field
overwrite in ``perform_redaction`` does not reach it, so custom-logger
callbacks that inspect this path see the unredacted prompt.
"""
litellm_params = model_call_details.get("litellm_params")
if not isinstance(litellm_params, dict):
return
proxy_server_request = litellm_params.get("proxy_server_request")
if not isinstance(proxy_server_request, dict):
return
body = proxy_server_request.get("body")
if not isinstance(body, dict):
return
if "messages" in body:
body["messages"] = [{"role": "user", "content": "redacted-by-litellm"}]
if "prompt" in body:
body["prompt"] = ""
if "input" in body:
body["input"] = ""
def _redact_additional_args_complete_input_dict(model_call_details: dict):
"""Redact the input-bearing keys inside additional_args.complete_input_dict.
Provider handlers (see ``litellm/llms/<provider>/.../handler.py`` and
``litellm/interactions/http_handler.py``) record the provider-native
request payload at ``additional_args["complete_input_dict"]`` so that
pre-call logs and OTel spans can show the wire-format request. The
flat-field overwrite in ``perform_redaction`` does not reach it, so
custom-logger callbacks (and the OTel exporter) see the unredacted
prompt even when message logging is disabled.
"""
additional_args = model_call_details.get("additional_args")
if not isinstance(additional_args, dict):
return
complete_input_dict = additional_args.get("complete_input_dict")
if not isinstance(complete_input_dict, dict):
return
if "messages" in complete_input_dict:
complete_input_dict["messages"] = [
{"role": "user", "content": "redacted-by-litellm"}
]
if "prompt" in complete_input_dict:
complete_input_dict["prompt"] = ""
if "input" in complete_input_dict:
complete_input_dict["input"] = ""
def _redact_model_response_dict_choices(choices, redacted_str: str):
for choice in choices:
if isinstance(choice, dict):
@ -141,6 +226,9 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
_redact_provider_specific_fields(
choice["message"].get("provider_specific_fields"), redacted_str
)
elif "delta" in choice and isinstance(choice["delta"], dict):
choice["delta"]["content"] = redacted_str
if "reasoning_content" in choice["delta"]:
@ -149,6 +237,9 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
_redact_provider_specific_fields(
choice["delta"].get("provider_specific_fields"), redacted_str
)
else:
_redact_choice_content(choice)
@ -164,6 +255,8 @@ def perform_redaction(model_call_details: dict, result):
model_call_details["prompt"] = ""
model_call_details["input"] = ""
_redact_standard_logging_object(model_call_details)
_redact_proxy_server_request_body(model_call_details)
_redact_additional_args_complete_input_dict(model_call_details)
# Redact streaming response
if (

View file

@ -349,3 +349,201 @@ class TestPerformRedaction:
assert redacted.output[0].content[0].text == "redacted-by-litellm"
assert response.output[0].content[0].text == "sensitive output"
def test_redacts_proxy_server_request_body_messages(self):
"""perform_redaction must scrub the proxy's body snapshot, not just
the top-level messages / prompt / input on model_call_details."""
details = {
"messages": [{"role": "user", "content": "sensitive input"}],
"litellm_params": {
"proxy_server_request": {
"url": "http://localhost/v1/chat/completions",
"method": "POST",
"body": {
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "CANARY_INPUT_should_be_redacted",
}
],
"prompt": "fallback prompt",
"input": "fallback input",
},
}
},
}
perform_redaction(details, None)
body = details["litellm_params"]["proxy_server_request"]["body"]
assert body["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert body["prompt"] == ""
assert body["input"] == ""
def test_redact_proxy_server_request_body_is_safe_when_missing(self):
"""No KeyError / TypeError when litellm_params / proxy_server_request /
body are absent, None, or not dicts."""
# litellm_params missing
perform_redaction({}, None)
# litellm_params present, proxy_server_request missing
perform_redaction({"litellm_params": {}}, None)
# proxy_server_request present, body is None
perform_redaction(
{"litellm_params": {"proxy_server_request": {"body": None}}}, None
)
# proxy_server_request is not a dict
perform_redaction(
{"litellm_params": {"proxy_server_request": "not-a-dict"}}, None
)
def test_redacts_provider_specific_fields_on_object_choices(self):
"""Anthropic reasoning content lives in both message.thinking_blocks
AND message.provider_specific_fields.thinking_blocks. The flat field
is scrubbed; ensure the duplicate is too (plus the signature blob)."""
result = litellm.ModelResponse(
choices=[
litellm.Choices(
message=litellm.Message(
content="message content",
role="assistant",
reasoning_content="message reasoning",
thinking_blocks=[
{"type": "thinking", "thinking": "CHAIN_OF_THOUGHT"}
],
provider_specific_fields={
"reasoning_content": "psf reasoning",
"thinking_blocks": [
{
"type": "thinking",
"thinking": "CHAIN_OF_THOUGHT",
"signature": "RAW_SIGNATURE_BLOB",
}
],
},
)
)
]
)
redacted = perform_redaction({}, result)
psf = redacted.choices[0].message.provider_specific_fields
assert psf["reasoning_content"] == "redacted-by-litellm"
assert psf["thinking_blocks"] is None
def test_redacts_provider_specific_fields_bedrock_reasoning_content_blocks(self):
"""Bedrock converse populates provider_specific_fields.reasoningContentBlocks.
Covered by code symmetry — assert it via the dict path."""
details = {
"standard_logging_object": {
"response": {
"choices": [
{
"message": {
"content": "answer",
"reasoning_content": "flat reasoning",
"provider_specific_fields": {
"reasoningContentBlocks": [
{
"reasoningText": {
"text": "BEDROCK_THOUGHT",
"signature": "sig",
}
}
],
},
}
}
]
}
}
}
perform_redaction(details, None)
choice = details["standard_logging_object"]["response"]["choices"][0]
psf = choice["message"]["provider_specific_fields"]
assert psf["reasoningContentBlocks"] is None
def test_redacts_provider_specific_fields_on_dict_delta(self):
"""Streaming-style dict path: choice['delta']['provider_specific_fields']."""
result = {
"choices": [
{
"delta": {
"content": "delta content",
"reasoning_content": "delta reasoning",
"thinking_blocks": ["delta thinking"],
"provider_specific_fields": {
"thinking_blocks": [
{"type": "thinking", "thinking": "STREAMED_THOUGHT"}
],
"reasoning_content": "psf streamed reasoning",
},
}
}
]
}
redacted = perform_redaction({}, result)
psf = redacted["choices"][0]["delta"]["provider_specific_fields"]
assert psf["thinking_blocks"] is None
assert psf["reasoning_content"] == "redacted-by-litellm"
def test_redact_provider_specific_fields_is_safe_when_absent(self):
"""Messages without provider_specific_fields (the common case) must
not raise."""
result = litellm.ModelResponse(
choices=[
litellm.Choices(message=litellm.Message(content="hi", role="assistant"))
]
)
redacted = perform_redaction({}, result)
assert redacted.choices[0].message.content == "redacted-by-litellm"
def test_redacts_additional_args_complete_input_dict_messages(self):
"""perform_redaction must scrub the provider-native request payload
stashed on additional_args.complete_input_dict. Anthropic-shape
content blocks (list of dicts) and the OpenAI prompt / input keys
must all be wiped."""
details = {
"additional_args": {
"complete_input_dict": {
"model": "claude-3-7-sonnet",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "CANARY_INPUT_should_be_redacted",
}
],
}
],
"prompt": "fallback prompt",
"input": "fallback input",
}
}
}
perform_redaction(details, None)
cid = details["additional_args"]["complete_input_dict"]
assert cid["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert cid["prompt"] == ""
assert cid["input"] == ""
def test_redact_additional_args_complete_input_dict_is_safe_when_missing(self):
"""No KeyError / TypeError when additional_args / complete_input_dict
are absent, None, or not dicts."""
# additional_args missing
perform_redaction({}, None)
# additional_args present, complete_input_dict missing
perform_redaction({"additional_args": {}}, None)
# complete_input_dict is None
perform_redaction({"additional_args": {"complete_input_dict": None}}, None)
# additional_args is not a dict
perform_redaction({"additional_args": "not-a-dict"}, None)