diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index dbc9cabdc7a..ada12c5c27d 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -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//.../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 ( diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index 60cfff6e4a0..599b1b795c7 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -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)