From 74b9bc5a401645ae232edb94694e210ceee4b998 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 30 Oct 2026 18:39:31 -0700 Subject: [PATCH] fix(proxy): keep the request's redaction opt-in on proxy-only failure logs --- litellm/proxy/litellm_pre_call_utils.py | 4 +- litellm/proxy/utils.py | 10 +++ ..._utils_model_creation_and_error_logging.py | 64 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index c1e541d20a9..269c4ee4450 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -740,7 +740,7 @@ def _key_or_team_allows_client_mock_response( ) -def _key_or_team_allows_client_message_redaction_opt_out( +def key_or_team_allows_client_message_redaction_opt_out( user_api_key_dict: UserAPIKeyAuth, ) -> bool: return _key_or_team_metadata_flag_is_true( @@ -2612,7 +2612,7 @@ async def add_litellm_data_to_request( # These keys are injected by the proxy itself below — user-supplied values # must not be trusted. _allow_client_mock_response: Final = _key_or_team_allows_client_mock_response(user_api_key_dict) - _allow_client_message_redaction_opt_out = _key_or_team_allows_client_message_redaction_opt_out(user_api_key_dict) + _allow_client_message_redaction_opt_out = key_or_team_allows_client_message_redaction_opt_out(user_api_key_dict) for _internal_key in _UNTRUSTED_ROOT_CONTROL_FIELDS: if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS: continue diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index b1055bb9124..1fed513f0a7 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -162,6 +162,7 @@ from litellm.litellm_core_utils.duration_parser import get_budget_window_start from litellm.litellm_core_utils.env_utils import get_env_float, get_env_int from litellm.litellm_core_utils.litellm_logging import BilledRoundsDispatch, Logging, deployment_pricing_model_info from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.litellm_core_utils.redact_messages import should_redact_failed_request from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.served_output_texts import ( @@ -253,6 +254,7 @@ from litellm.proxy.hooks.sensitive_data_routing import ( # noqa: F401, RUF100 from litellm.proxy.litellm_pre_call_utils import ( LiteLLMProxyRequestSetup, add_guardrails_from_auth_metadata, + key_or_team_allows_client_message_redaction_opt_out, normalized_metadata_slot, ) from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at @@ -3966,6 +3968,7 @@ class ProxyLogging: from litellm._uuid import uuid request_data.setdefault("litellm_call_id", str(uuid.uuid4())) + redact: Final = should_redact_failed_request(request_fields) litellm_logging_obj, data = litellm.utils.function_setup( original_function=route or "IGNORE_THIS", @@ -3974,6 +3977,13 @@ class ProxyLogging: **_proxy_only_error_log_fields( # pyright: ignore[reportArgumentType] # the allowlist never carries is_async_call request_fields, logged_metadata_keys ), + # The allowlist leaves out the request's redaction choice, so carry it over. As in the pre-call + # checks, an opt-out only counts for a key or team allowed to send one + **( + {"turn_off_message_logging": redact} + if redact or key_or_team_allows_client_message_redaction_opt_out(user_api_key_dict) + else {} + ), ) request_data["litellm_logging_obj"] = litellm_logging_obj # rebind-ok: lifted then popped by the caller request_data.update({REJECTED_BEFORE_REQUEST_SETUP_KEY: REJECTED_BEFORE_REQUEST_SETUP}) diff --git a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py index 3cdd2edb2fb..2b5909778d2 100644 --- a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py +++ b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py @@ -202,6 +202,70 @@ async def test_proxy_only_error_log_keeps_tags_and_drops_caller_logging_fields( assert details["litellm_params"][metadata_field]["user_api_key_team_id"] == "team-a" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "opt_in", + [ + {"turn_off_message_logging": True}, + {"metadata": {"turn_off_message_logging": True}}, + {"metadata": {"headers": {"x-litellm-enable-message-redaction": "true"}}}, + ], + ids=["body_flag", "metadata_flag", "enable_header"], +) +async def test_proxy_only_error_log_keeps_the_request_redaction_opt_in(opt_in: Mapping[str, object]) -> None: + secret: Final = "my ssn is 123-45-6789" + request_data: Final[dict[str, object]] = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": secret}], + **opt_in, + } + + await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error( + request_data=request_data, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-rejected", team_id="team-a", request_route="/v1/rerank"), + route="/v1/rerank", + original_exception=HTTPException(status_code=400, detail=f"blocked: {secret}"), + ) + + payload: Final = request_data["litellm_logging_obj"].model_call_details["standard_logging_object"] + assert secret not in json.dumps(payload, default=str) + assert "blocked: redacted-by-litellm" in payload["error_str"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opt_out_allowed", [True, False], ids=["allowed", "not_allowed"]) +@pytest.mark.parametrize( + "opt_out", + [{"turn_off_message_logging": False}, {"metadata": {"headers": {"litellm-disable-message-redaction": "true"}}}], + ids=["body_flag", "disable_header"], +) +async def test_proxy_only_error_log_keeps_a_redaction_opt_out_only_for_a_key_allowed_to_send_one( + monkeypatch: pytest.MonkeyPatch, opt_out: Mapping[str, object], opt_out_allowed: bool +) -> None: + secret: Final = "my ssn is 123-45-6789" + monkeypatch.setattr(litellm, "turn_off_message_logging", True) + request_data: Final[dict[str, object]] = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": secret}], + **opt_out, + } + + await ProxyLogging(user_api_key_cache=DualCache())._handle_logging_proxy_only_error( + request_data=request_data, + user_api_key_dict=UserAPIKeyAuth( + api_key="sk-rejected", + team_id="team-a", + request_route="/v1/rerank", + metadata={"allow_client_message_redaction_opt_out": opt_out_allowed}, + ), + route="/v1/rerank", + original_exception=HTTPException(status_code=400, detail=f"blocked: {secret}"), + ) + + payload: Final = request_data["litellm_logging_obj"].model_call_details["standard_logging_object"] + assert (f"blocked: {secret}" in payload["error_str"]) is opt_out_allowed + + @pytest.mark.asyncio @pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"]) async def test_proxy_only_error_log_drops_malformed_metadata_tags(metadata_field: str) -> None: