mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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:
commit
2e5a54f28c
4 changed files with 148 additions and 10 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue