mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
fix(logging): redact provider_specific_fields and request-body snapshots when message logging is off
This commit is contained in:
parent
d04373f4ce
commit
e2d2b60a3b
2 changed files with 291 additions and 0 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue