From f77eeba186c31413d317ee534ff02d41cedb1269 Mon Sep 17 00:00:00 2001 From: Tsachi Shushan Date: Tue, 10 Feb 2026 06:05:53 +0200 Subject: [PATCH] fix: redaction headers ignored when sent via proxy (#20740) * fix: redaction headers ignored when sent via proxy When requests go through the proxy, `litellm_params["litellm_metadata"]` is always set (even when `None`), so `get_metadata_variable_name_from_kwargs` always returns "litellm_metadata". The redaction code then reads `None` instead of the actual metadata dict that contains the headers. Add a fallback to read from `metadata` when `litellm_metadata` is not a dict, so `x-litellm-enable-message-redaction` and related headers work correctly in the proxy flow. Fixes #20739 Co-Authored-By: Claude Opus 4.6 * fix: normalize non-dict metadata after fallback in redact_messages After falling back from litellm_metadata to metadata, ensure the value is always a dict so .get("headers") never raises on None/non-dict inputs. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- litellm/litellm_core_utils/redact_messages.py | 9 +- .../test_redact_messages.py | 145 ++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/litellm_core_utils/test_redact_messages.py diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index aa763dc9899..5d6d1fbc1c5 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -140,9 +140,14 @@ def should_redact_message_logging(model_call_details: dict) -> bool: metadata_field = get_metadata_variable_name_from_kwargs(litellm_params) metadata = litellm_params.get(metadata_field, {}) - + if not isinstance(metadata, dict): + # Fall back: litellm_metadata was None, try metadata + metadata = litellm_params.get("metadata", {}) + if not isinstance(metadata, dict): + metadata = {} + # Get headers from the metadata - request_headers = metadata.get("headers", {}) if isinstance(metadata, dict) else {} + request_headers = metadata.get("headers", {}) # Check for headers that explicitly control redaction if request_headers and bool( diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py new file mode 100644 index 00000000000..d7df7823aee --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -0,0 +1,145 @@ +""" +Tests for litellm.litellm_core_utils.redact_messages.should_redact_message_logging + +Covers the proxy flow where headers arrive in litellm_params["metadata"]["headers"] +but litellm_params["litellm_metadata"] is None. +""" + +import pytest + +import litellm +from litellm.litellm_core_utils.redact_messages import should_redact_message_logging + + +@pytest.fixture(autouse=True) +def _reset_global_redaction(): + """Ensure the global setting is off for every test.""" + original = litellm.turn_off_message_logging + litellm.turn_off_message_logging = False + yield + litellm.turn_off_message_logging = original + + +def _make_model_call_details( + metadata_headers=None, + litellm_metadata=None, + metadata=None, + standard_callback_dynamic_params=None, +): + """Build a model_call_details dict that mimics real proxy/SDK flows.""" + litellm_params = {} + if metadata is not None: + litellm_params["metadata"] = metadata + elif metadata_headers is not None: + litellm_params["metadata"] = {"headers": metadata_headers} + else: + litellm_params["metadata"] = {} + + # get_litellm_params always sets this key (even when value is None) + litellm_params["litellm_metadata"] = litellm_metadata + + details = {"litellm_params": litellm_params} + if standard_callback_dynamic_params is not None: + details["standard_callback_dynamic_params"] = standard_callback_dynamic_params + return details + + +class TestShouldRedactMessageLogging: + """Unit tests for should_redact_message_logging().""" + + # ---- proxy flow: headers in metadata, litellm_metadata is None ---- + + def test_enable_redaction_via_x_header_proxy_flow(self): + """x-litellm-enable-message-redaction header should enable redaction + even when litellm_metadata is None (proxy path).""" + details = _make_model_call_details( + metadata_headers={"x-litellm-enable-message-redaction": "true"}, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is True + + def test_enable_redaction_via_old_header_proxy_flow(self): + """litellm-enable-message-redaction header should enable redaction + even when litellm_metadata is None (proxy path).""" + details = _make_model_call_details( + metadata_headers={"litellm-enable-message-redaction": "true"}, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is True + + def test_disable_redaction_via_header_proxy_flow(self): + """litellm-disable-message-redaction should suppress redaction + even when global setting is on, and litellm_metadata is None.""" + litellm.turn_off_message_logging = True + details = _make_model_call_details( + metadata_headers={"litellm-disable-message-redaction": "true"}, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is False + + # ---- SDK direct-call flow: headers in litellm_metadata ---- + + def test_enable_redaction_via_header_in_litellm_metadata(self): + """Headers inside litellm_metadata (SDK direct call) should work.""" + details = _make_model_call_details( + litellm_metadata={"headers": {"x-litellm-enable-message-redaction": "true"}}, + ) + assert should_redact_message_logging(details) is True + + # ---- no headers at all ---- + + def test_no_headers_defaults_to_global_off(self): + """Without headers, falls back to global setting (False).""" + details = _make_model_call_details( + metadata_headers=None, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is False + + def test_no_headers_global_on(self): + """Without headers, respects global turn_off_message_logging=True.""" + litellm.turn_off_message_logging = True + details = _make_model_call_details( + metadata_headers=None, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is True + + # ---- dynamic params take precedence ---- + + def test_dynamic_param_enables_redaction(self): + """Dynamic turn_off_message_logging=True should enable redaction.""" + details = _make_model_call_details( + metadata_headers={}, + litellm_metadata=None, + standard_callback_dynamic_params={"turn_off_message_logging": True}, + ) + assert should_redact_message_logging(details) is True + + def test_dynamic_param_false_overrides_header(self): + """Dynamic turn_off_message_logging=False should take precedence over enable header.""" + details = _make_model_call_details( + metadata_headers={"x-litellm-enable-message-redaction": "true"}, + litellm_metadata=None, + standard_callback_dynamic_params={"turn_off_message_logging": False}, + ) + assert should_redact_message_logging(details) is False + + # ---- non-dict metadata safety ---- + + def test_both_metadata_fields_none(self): + """When both litellm_metadata and metadata are None, should not raise.""" + details = _make_model_call_details( + metadata=None, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is False + + def test_both_metadata_fields_none_global_on(self): + """When both metadata fields are None but global is on, should still return True.""" + litellm.turn_off_message_logging = True + details = _make_model_call_details( + metadata=None, + litellm_metadata=None, + ) + assert should_redact_message_logging(details) is True