From 30533c9ae52f0d3854ffbf101c182bcf0d7d6178 Mon Sep 17 00:00:00 2001 From: Brendan Smith-Elion Date: Tue, 13 Oct 2026 10:24:28 -0400 Subject: [PATCH] fix(logging): redact secrets in utils.print_verbose stdout path print_verbose routes through verbose_logger, which SecretRedactionFilter already covers, but its set_verbose stdout fallback printed the statement raw. Callers pass dicts like model_call_details that carry api_key, so the key reached stdout whenever set_verbose was on Mapping payloads now go through redact_credentials_in_payload before the print, the same helper the verbose call sites already use, so credential-named values print as REDACTED and every other field survives. String statements are printed as before because their call sites redact before formatting, and running redact_secrets over them again collapses an already redacted api_key='REDACTED' span. print_statement is typed as object so the narrowing is checked Regression test asserts the api_key never reaches stdout when set_verbose is on and that the verbose_logger path stays redacted. The stdout test fails on the pre-fix code and passes on the fix Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019aU734P5gCsAaDDgyvMcEf --- litellm/utils.py | 9 +++++++-- tests/unit/test_utils.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index 3f10d2a3ebd..e9d53379235 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -541,7 +541,7 @@ last_fetched_at_keys: Final = None ############################################################ def print_verbose( - print_statement, + print_statement: object, logger_only: bool = False, log_level: Literal["DEBUG", "INFO", "ERROR"] = "DEBUG", ): @@ -553,7 +553,12 @@ def print_verbose( elif log_level == "ERROR": verbose_logger.error(print_statement) if litellm.set_verbose is True and logger_only is False: - print(print_statement) # noqa: T201 + stdout_statement: Final = ( + redact_credentials_in_payload(print_statement) + if isinstance(print_statement, Mapping) + else print_statement + ) + print(stdout_statement) # noqa: T201 except Exception: pass diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 6ad4f75f97f..6500f7f4b7e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -108,6 +108,7 @@ from litellm.utils import ( get_valid_models, is_cached_message, is_prompt_caching_valid_prompt, + print_verbose, trim_messages, validate_environment, validate_chat_completion_tool_choice, @@ -6669,6 +6670,39 @@ class TestFinalOptionalParamsLineRedaction: assert "'temperature': 0.25" in printed +class TestPrintVerboseRedaction: + """print_verbose() must not leak secrets to stdout when callers pass dicts like + model_call_details that contain api_key fields.""" + + SECRET_VALUE: Final = "sk-ant-test-DO-NOT-LEAK-12345" + + def _payload(self) -> Mapping[str, object]: + return { + "model": "claude-3-5-sonnet-20241022", + "api_key": self.SECRET_VALUE, + "messages": [{"role": "user", "content": "hi"}], + } + + def test_utils_print_verbose_redacts_stdout_when_set_verbose( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(litellm, "set_verbose", True) + print_verbose(self._payload()) + out: Final = capsys.readouterr().out + assert self.SECRET_VALUE not in out, f"api_key leaked to stdout: {out!r}" + assert "'api_key': 'REDACTED'" in out + assert "'model': 'claude-3-5-sonnet-20241022'" in out + + def test_utils_print_verbose_routes_through_logger_redacted( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(litellm, "set_verbose", False) + caplog.set_level(logging.DEBUG, logger="LiteLLM") + print_verbose(self._payload()) + leaked: Final = [r.getMessage() for r in caplog.records if self.SECRET_VALUE in r.getMessage()] + assert not leaked, f"api_key leaked through verbose_logger: {leaked!r}" + + class TestDropParamsStringCoercion: @pytest.mark.parametrize("drop_params", ["true", "True", True]) def test_truthy_drop_params_drops_unsupported_temperature(self, drop_params, monkeypatch):