Merge pull request #39538 from BerriAI/litellm_redact_optional_params_verbose_line

fix(utils): redact credentials nested in extra_body on the verbose optional-params line
This commit is contained in:
Mateo Wang 2026-09-03 14:36:39 -07:00 committed by GitHub
commit 2e5a54f28c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 148 additions and 10 deletions

View file

@ -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,30 +226,31 @@ 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. 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)
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:
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)

View file

@ -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():
@ -4710,7 +4718,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,

View file

@ -372,3 +372,61 @@ 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
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 + 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
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"

View file

@ -5983,3 +5983,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