From 912572bfa5c0807f0bd60f94509a47a5014ffd2f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:53:50 -0700 Subject: [PATCH 1/3] fix(utils): redact credentials nested in extra_body on the verbose optional-params line The "Final returned optional params" line printed whatever the caller nested inside extra_body, so a credential tucked in there reached stdout in plaintext one line after the request line that already redacts it. The call site now runs redact_credentials_in_payload behind a guard reading both of print_verbose's consumers, litellm.set_verbose and the LiteLLM logger's DEBUG level, so the line prints in exactly the cases it did before and the walk costs nothing when nothing would read it. --- litellm/utils.py | 11 ++++- tests/test_litellm/test_utils.py | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/litellm/utils.py b/litellm/utils.py index f5a4f8a38f8..dde21c53c24 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -544,6 +544,14 @@ def print_verbose( pass +def _print_verbose_is_active() -> bool: + """Whether print_verbose would reach either of its two consumers, so a call site can skip + building a payload nothing would read. _is_debugging_on() is not the same predicate: it reads + litellm._logging.set_verbose, while print_verbose's print reads litellm.set_verbose, and + assigning the documented litellm.set_verbose = True rebinds only the latter.""" + return litellm.set_verbose is True or verbose_logger.isEnabledFor(logging.DEBUG) + + ####### CLIENT ################### # make it easy to log if completion/embedding runs succeeded or failed + see what happened | Non-Blocking def custom_llm_setup(): @@ -4705,7 +4713,8 @@ def get_optional_params( openai_params=list(DEFAULT_CHAT_COMPLETION_PARAM_VALUES.keys()), additional_drop_params=additional_drop_params, ) - print_verbose(f"Final returned optional params: {optional_params}") + if _print_verbose_is_active(): + print_verbose(f"Final returned optional params: {redact_credentials_in_payload(optional_params)}") optional_params = _apply_openai_param_overrides( optional_params=optional_params, non_default_params=non_default_params, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 59d5902e138..4ec7bfe2786 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -5867,3 +5867,73 @@ class TestVerboseRequestLineRedaction: assert "model='gpt-3.5-turbo'" in printed assert "max_tokens=17" in printed assert "temperature=0.25" in printed + + +class TestFinalOptionalParamsLineRedaction: + """A verbose run echoes the fully built optional params too, and `extra_body` carries whatever the + caller nested inside it straight onto that line, so a credential tucked in there lands in a terminal + or a log drain in plaintext. It has to be redacted on both surfaces `print_verbose` writes to, and the + line has to keep printing on both, because `litellm.set_verbose` and the DEBUG logger are independent + switches and neither implies the other.""" + + FAKE_NESTED_KEY: Final = "sk-fake-lit6835-nested-0000000000" + + def _complete(self, **kwargs) -> None: + litellm.completion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "hello"}], + mock_response="hi", + **kwargs, + ) + + def _printed_line(self, capsys) -> str: + captured: Final = capsys.readouterr() + return "\n".join( + line for line in (captured.out + captured.err).splitlines() if "Final returned optional params" in line + ) + + def test_nested_credential_is_redacted_when_only_set_verbose_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", True) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + printed: Final = self._printed_line(capsys) + + assert printed + assert self.FAKE_NESTED_KEY not in printed + assert "'api_key': 'REDACTED'" in printed + assert "'name': 'openai'" in printed + + def test_line_still_reaches_the_logger_when_only_the_debug_logger_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", False) + with caplog.at_level(logging.DEBUG, logger=verbose_logger.name): + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + logged: Final = "\n".join( + record.getMessage() + for record in caplog.records + if "Final returned optional params" in record.getMessage() + ) + + assert logged + assert self.FAKE_NESTED_KEY not in logged + assert "'name': 'openai'" in logged + + def test_nothing_is_emitted_when_neither_verbose_switch_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", False) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + captured: Final = capsys.readouterr() + + assert "Final returned optional params" not in captured.out + captured.err + assert self.FAKE_NESTED_KEY not in captured.out + captured.err + + def test_ordinary_optional_params_still_reach_the_line(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", True) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(max_tokens=17, temperature=0.25) + printed: Final = self._printed_line(capsys) + + assert "'max_tokens': 17" in printed + assert "'temperature': 0.25" in printed From 3abed5f4c91b6dcbc218d8eb602faf257df029ba Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:12:04 -0700 Subject: [PATCH 2/3] fix(masker): hide containers at the redaction depth limit instead of passing them through --- .../sensitive_data_masker.py | 16 +++++++------- .../test_sensitive_data_masker.py | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index f82d0acb581..08432ba20c6 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -226,30 +226,30 @@ def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, ob and non-string secrets are covered too, which is what a payload rendered straight to stdout needs. ``None`` is preserved so an unset credential still reads as unset, and lists and tuples are rebuilt element by element so a - credential nested inside one is caught as well. + credential nested inside one is caught as well. A container sitting at the + recursion limit is replaced wholesale rather than passed through, so nesting a + payload deeper than the limit hides it instead of exposing it. """ return _redact_mapping(data, 0) def _redact_mapping(data: Mapping[str, object], depth: int) -> Mapping[str, object]: - if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: - return data return {key: _redact_entry(key, value, depth) for key, value in data.items()} def _redact_entry(key: str, value: object, depth: int) -> object: if value is not None and _default_masker.is_sensitive_key(key): return REDACTED + if not isinstance(value, (Mapping, list, tuple)): + return value + if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + return REDACTED if isinstance(value, Mapping): return _redact_mapping(value, depth + 1) - if isinstance(value, (list, tuple)): - return _redact_sequence(value, depth + 1) - return value + return _redact_sequence(value, depth + 1) def _redact_sequence(values: Sequence[object], depth: int) -> Sequence[object]: - if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: - return values redacted: Final = tuple(_redact_entry("", item, depth) for item in values) return redacted if isinstance(values, tuple) else list(redacted) diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 26fb7674cb6..917ec1fced8 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -372,3 +372,24 @@ def test_redact_credentials_in_payload_reaches_credentials_nested_in_sequences() assert result["metadata"]["upstreams"][0]["aws_secret_access_key"] == "REDACTED" assert isinstance(result["metadata"]["upstreams"], tuple) assert result["messages"] == [{"role": "user", "content": "hello"}] + + +@pytest.mark.parametrize("wrap", ["mapping", "sequence"]) +def test_redact_credentials_in_payload_hides_containers_at_the_recursion_limit(wrap): + """The recursion limit exists to bound the walk, not to grant an exemption, so a caller who + buries a credential deeper than the limit must get the container hidden rather than handed + back verbatim. Nesting through lists costs depth twice as fast as nesting through mappings, + so both shapes are pushed well past the limit here.""" + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + fake_key = "sk-fake-lit6835-past-the-limit" + node = {"api_key": fake_key} + for _ in range(2 * DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + 1): + node = {"extra_body": node} if wrap == "mapping" else {"providers": [node]} + + result = redact_credentials_in_payload({**node, "max_tokens": 17}) + + assert fake_key not in str(result) + assert "REDACTED" in str(result) + assert result["max_tokens"] == 17 From 86c5159d96ac76f12738e30b6e2b3ddeba645480 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:23:22 -0700 Subject: [PATCH 3/3] fix(masker): bound the credential walk at the generic recursion depth Failing closed at the sensitive-data masker's depth of 10 turned an ordinary nested tool JSON schema into REDACTED leaves, because a list level costs two depth. The walk now bounds on DEFAULT_MAX_RECURSE_DEPTH, which no real payload reaches, and the masker's own limit is left alone. --- .../sensitive_data_masker.py | 11 ++--- .../test_sensitive_data_masker.py | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 08432ba20c6..15b2c879224 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -3,7 +3,7 @@ from typing import Any, Final from pydantic import BaseModel -from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH, DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER from litellm.litellm_core_utils.secret_redaction import REDACTED @@ -226,9 +226,10 @@ def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, ob and non-string secrets are covered too, which is what a payload rendered straight to stdout needs. ``None`` is preserved so an unset credential still reads as unset, and lists and tuples are rebuilt element by element so a - credential nested inside one is caught as well. A container sitting at the - recursion limit is replaced wholesale rather than passed through, so nesting a - payload deeper than the limit hides it instead of exposing it. + credential nested inside one is caught as well. The walk is bounded only to stop + runaway recursion, and a container sitting at that bound is replaced wholesale + rather than passed through, so burying a credential deeper than the walk goes + hides it instead of exposing it. """ return _redact_mapping(data, 0) @@ -242,7 +243,7 @@ def _redact_entry(key: str, value: object, depth: int) -> object: return REDACTED if not isinstance(value, (Mapping, list, tuple)): return value - if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + if depth >= DEFAULT_MAX_RECURSE_DEPTH: return REDACTED if isinstance(value, Mapping): return _redact_mapping(value, depth + 1) diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 917ec1fced8..fadc4ca49e9 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -380,12 +380,12 @@ def test_redact_credentials_in_payload_hides_containers_at_the_recursion_limit(w buries a credential deeper than the limit must get the container hidden rather than handed back verbatim. Nesting through lists costs depth twice as fast as nesting through mappings, so both shapes are pushed well past the limit here.""" - from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload fake_key = "sk-fake-lit6835-past-the-limit" node = {"api_key": fake_key} - for _ in range(2 * DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + 1): + for _ in range(2 * DEFAULT_MAX_RECURSE_DEPTH + 1): node = {"extra_body": node} if wrap == "mapping" else {"providers": [node]} result = redact_credentials_in_payload({**node, "max_tokens": 17}) @@ -393,3 +393,40 @@ def test_redact_credentials_in_payload_hides_containers_at_the_recursion_limit(w assert fake_key not in str(result) assert "REDACTED" in str(result) assert result["max_tokens"] == 17 + + +def test_redact_credentials_in_payload_leaves_a_realistic_tool_schema_intact(): + """The bound must not eat ordinary payloads: a tool whose JSON schema nests an array of + objects inside a nested object is what agent traffic looks like, and the verbose line is + useless if those leaves come back as REDACTED.""" + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + tool = { + "type": "function", + "function": { + "name": "search_orders", + "parameters": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"sku": {"type": "string"}, "qty": {"type": "integer"}}, + }, + } + }, + } + }, + }, + }, + } + + result = redact_credentials_in_payload({"model": "gpt-4o-mini", "tools": [tool], "api_key": "sk-fake-lit6835"}) + + assert "REDACTED" not in str(result["tools"]) + assert result["tools"][0] == tool + assert result["api_key"] == "REDACTED"