Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions litellm/litellm_core_utils/initialize_dynamic_callback_params.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
from typing import Dict, Optional
from typing import Any, Dict, Iterator, Optional

from litellm.types.utils import StandardCallbackDynamicParams

_CLIENT_CALLBACK_METADATA_SLOTS: tuple[str, ...] = ("litellm_metadata", "metadata")


def iter_client_callback_metadata_dicts(
kwargs: dict[str, Any],
) -> Iterator[tuple[str, dict[str, Any]]]:
litellm_params = kwargs.get("litellm_params")
if isinstance(litellm_params, dict):
nested = litellm_params.get("metadata")
if isinstance(nested, dict):
yield "litellm_params.metadata", nested
for key in _CLIENT_CALLBACK_METADATA_SLOTS:
candidate = kwargs.get(key)
if isinstance(candidate, dict):
yield key, candidate


def _is_env_reference(value: object) -> bool:
return isinstance(value, str) and "os.environ/" in value
Expand Down Expand Up @@ -55,6 +71,7 @@ def validate_no_callback_env_reference(param: str, value: object, *, source: str
"dd_site",
"dd_agent_host",
"dd_agent_port",
"turn_off_message_logging",
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
]

_request_blocked_callback_params = {
Expand Down Expand Up @@ -87,19 +104,13 @@ def initialize_standard_callback_dynamic_params(
validate_no_callback_env_reference(param, _param_value, source="request body")
standard_callback_dynamic_params[param] = _param_value # type: ignore

# 2. Fallback: check "metadata" or "litellm_params" -> "metadata"
metadata = (kwargs.get("metadata") or {}).copy()
litellm_params = kwargs.get("litellm_params") or {}
if isinstance(litellm_params, dict):
metadata.update(litellm_params.get("metadata") or {})

if isinstance(metadata, dict):
for slot_label, metadata in iter_client_callback_metadata_dicts(kwargs):
for param in _supported_callback_params:
if param in _request_blocked_callback_params:
continue
if param not in standard_callback_dynamic_params and param in metadata:
_param_value = metadata.get(param)
validate_no_callback_env_reference(param, _param_value, source="metadata")
validate_no_callback_env_reference(param, _param_value, source=slot_label)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
standard_callback_dynamic_params[param] = _param_value # type: ignore

return standard_callback_dynamic_params
10 changes: 10 additions & 0 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,16 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router:
metadata = _coerce_metadata_to_dict(request_body.get(metadata_key))
if metadata is not None:
_check_banned_params(metadata, general_settings, llm_router, model)
litellm_params = _coerce_metadata_to_dict(request_body.get("litellm_params"))
if litellm_params is not None:
litellm_params_metadata = _coerce_metadata_to_dict(litellm_params.get("metadata"))
if litellm_params_metadata is not None:
_check_banned_params(
litellm_params_metadata,
general_settings,
llm_router,
model,
)
return True


Expand Down
31 changes: 24 additions & 7 deletions litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from litellm._service_logger import ServiceLogging
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
iter_client_callback_metadata_dicts,
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
from litellm.proxy._types import (
Expand Down Expand Up @@ -302,6 +305,24 @@ def _key_or_team_allows_client_pricing_override(
)


def _strip_client_message_redaction_opt_out(data: dict[str, Any]) -> None:
stripped: list[str] = []
if "turn_off_message_logging" in data and _is_false_like(data["turn_off_message_logging"]):
stripped.append("turn_off_message_logging")
data.pop("turn_off_message_logging", None)
for slot_label, metadata in iter_client_callback_metadata_dicts(data):
if "turn_off_message_logging" in metadata and _is_false_like(metadata["turn_off_message_logging"]):
stripped.append(f"{slot_label}.turn_off_message_logging")
metadata.pop("turn_off_message_logging", None)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
if stripped:
verbose_proxy_logger.debug(
"Stripped client-supplied message-redaction opt-out fields from request body: %s. "
"Set `allow_client_message_redaction_opt_out: true` on the key or team metadata "
"to keep these values.",
", ".join(stripped),
)


def _strip_client_pricing_overrides(data: Dict[str, Any]) -> None:
"""Drop pricing overrides from the request body and any metadata variant.

Expand Down Expand Up @@ -1308,13 +1329,6 @@ async def add_litellm_data_to_request(
_headers,
allow_client_message_redaction_opt_out=_allow_client_message_redaction_opt_out,
)
if (
not _allow_client_message_redaction_opt_out
and litellm.turn_off_message_logging is True
and "turn_off_message_logging" in data
and _is_false_like(data["turn_off_message_logging"])
):
data.pop("turn_off_message_logging", None)
verbose_proxy_logger.debug(f"Request Headers: {_headers}")
verbose_proxy_logger.debug(f"Raw Headers: {_raw_headers}")

Expand Down Expand Up @@ -1466,6 +1480,9 @@ async def add_litellm_data_to_request(
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
_strip_client_pricing_overrides(data)

if not _allow_client_message_redaction_opt_out and litellm.turn_off_message_logging is True:
_strip_client_message_redaction_opt_out(data)

# Fill in the proxy_server_request body snapshot now that metadata has
# been parsed. Consumers (standard_logging_payload, lago,
# spend_tracking_utils, streaming_iterator) read `body` to audit the
Expand Down
59 changes: 23 additions & 36 deletions tests/logging_callback_tests/test_logging_redaction_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,69 +56,56 @@ async def test_global_redaction_on():
)


@pytest.mark.parametrize("turn_off_message_logging", [True, False])
@pytest.mark.parametrize(
"dynamic_turn_off, expect_redacted",
[(True, True), (False, False)],
)
@pytest.mark.asyncio
async def test_global_redaction_ignores_dynamic_param(turn_off_message_logging):
"""
Request-body `turn_off_message_logging` is no longer honored as a dynamic
callback param — global setting (or admin-configured key/team config) wins.
With global redaction ON, the caller cannot disable redaction via the
request body.
"""
async def test_dynamic_turn_off_message_logging_overrides_global_on(dynamic_turn_off, expect_redacted):
litellm.turn_off_message_logging = True
test_custom_logger = TestCustomLogger()
litellm.callbacks = [test_custom_logger]
response = await litellm.acompletion(
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
turn_off_message_logging=turn_off_message_logging,
turn_off_message_logging=dynamic_turn_off,
mock_response="hello",
)

await asyncio.sleep(1)
standard_logging_payload = test_custom_logger.logged_standard_logging_payload
assert standard_logging_payload is not None
print(
"logged standard logging payload",
json.dumps(standard_logging_payload, indent=2),
)

response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
expected_response_content = "redacted-by-litellm" if expect_redacted else "hello"
expected_message_content = "redacted-by-litellm" if expect_redacted else "hi"
assert standard_logging_payload["response"]["choices"][0]["message"]["content"] == expected_response_content
assert standard_logging_payload["messages"][0]["content"] == expected_message_content


@pytest.mark.parametrize("turn_off_message_logging", [True, False])
@pytest.mark.parametrize(
"dynamic_turn_off, expect_redacted",
[(True, True), (False, False)],
)
@pytest.mark.asyncio
async def test_global_redaction_off_ignores_dynamic_param(turn_off_message_logging):
"""
Request-body `turn_off_message_logging` is no longer honored as a dynamic
callback param — global setting (or admin-configured key/team config) wins.
With global redaction OFF, the caller cannot enable redaction via the
request body.
"""
async def test_dynamic_turn_off_message_logging_overrides_global_off(dynamic_turn_off, expect_redacted):
litellm.turn_off_message_logging = False
test_custom_logger = TestCustomLogger()
litellm.callbacks = [test_custom_logger]
response = await litellm.acompletion(
await litellm.acompletion(
model="gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
turn_off_message_logging=turn_off_message_logging,
turn_off_message_logging=dynamic_turn_off,
mock_response="hello",
)

await asyncio.sleep(1)
standard_logging_payload = test_custom_logger.logged_standard_logging_payload
assert standard_logging_payload is not None
print(
"logged standard logging payload",
json.dumps(standard_logging_payload, indent=2),
)
assert (
standard_logging_payload["response"]["choices"][0]["message"]["content"]
== "hello"
)
assert standard_logging_payload["messages"][0]["content"] == "hi"

expected_response_content = "redacted-by-litellm" if expect_redacted else "hello"
expected_message_content = "redacted-by-litellm" if expect_redacted else "hi"
assert standard_logging_payload["response"]["choices"][0]["message"]["content"] == expected_response_content
assert standard_logging_payload["messages"][0]["content"] == expected_message_content


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,53 @@

from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
initialize_standard_callback_dynamic_params,
iter_client_callback_metadata_dicts,
)


def test_iter_client_callback_metadata_dicts_covers_all_read_paths():
md = {"m": 1}
lm = {"lm": 1}
lp_md = {"lp": 1}
slots = dict(
iter_client_callback_metadata_dicts(
{
"metadata": md,
"litellm_metadata": lm,
"litellm_params": {"metadata": lp_md},
}
)
)
assert slots == {
"metadata": md,
"litellm_metadata": lm,
"litellm_params.metadata": lp_md,
}


def test_iter_client_callback_metadata_dicts_skips_non_dict_slots():
slots = list(
iter_client_callback_metadata_dicts(
{
"metadata": "not-a-dict",
"litellm_metadata": None,
"litellm_params": {"metadata": []},
}
)
)
assert slots == []


def test_extractor_reads_turn_off_message_logging_from_every_slot():
for kwargs in (
{"metadata": {"turn_off_message_logging": True}},
{"litellm_metadata": {"turn_off_message_logging": True}},
{"litellm_params": {"metadata": {"turn_off_message_logging": True}}},
):
params = initialize_standard_callback_dynamic_params(kwargs)
assert params.get("turn_off_message_logging") is True, kwargs


def test_resolves_plain_values_at_top_level():
kwargs = {
"langfuse_public_key": "pk-test",
Expand All @@ -36,6 +80,33 @@ def test_resolves_plain_values_from_metadata():
assert params.get("langfuse_host") == "https://test.langfuse.com"


def test_litellm_params_metadata_overrides_metadata():
kwargs = {
"metadata": {
"langfuse_public_key": "pk-meta",
},
"litellm_params": {
"metadata": {
"langfuse_public_key": "pk-litellm-params",
}
},
}

params = initialize_standard_callback_dynamic_params(kwargs)

assert params.get("langfuse_public_key") == "pk-litellm-params"


def test_top_level_kwargs_overrides_metadata_slots():
kwargs = {
"langfuse_public_key": "from-top-level",
"metadata": {"langfuse_public_key": "from-metadata"},
"litellm_params": {"metadata": {"langfuse_public_key": "from-litellm-params"}},
}
params = initialize_standard_callback_dynamic_params(kwargs)
assert params.get("langfuse_public_key") == "from-top-level"


def test_env_reference_at_top_level_raises_with_guidance():
kwargs = {"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY"}

Expand Down Expand Up @@ -100,11 +171,17 @@ def test_non_string_values_are_not_flagged():
assert params.get("langsmith_sampling_rate") == 0.5


def test_turn_off_message_logging_not_extracted_from_request():
"""turn_off_message_logging is admin-only — must not be settable via request."""
kwargs = {"turn_off_message_logging": True}
@pytest.mark.parametrize(
"kwargs,expected",
[
({"turn_off_message_logging": False}, False),
({"turn_off_message_logging": "False"}, "False"),
({"metadata": {"turn_off_message_logging": True}}, True),
],
)
def test_turn_off_message_logging_extracted_from_kwargs(kwargs, expected):
params = initialize_standard_callback_dynamic_params(kwargs)
assert params.get("turn_off_message_logging") is None
assert params.get("turn_off_message_logging") == expected


def test_empty_kwargs_returns_empty_params():
Expand Down
15 changes: 15 additions & 0 deletions tests/test_litellm/proxy/auth/test_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2104,6 +2104,21 @@ def test_observability_field_in_metadata_dict_is_rejected(
)
assert field in str(exc.value)

def test_observability_field_in_litellm_params_metadata_is_rejected(self):
with pytest.raises(ValueError) as exc:
is_request_body_safe(
request_body={
"model": "gpt-4",
"litellm_params": {
"metadata": {"turn_off_message_logging": False}
},
},
general_settings={},
llm_router=None,
model="gpt-4",
)
assert "turn_off_message_logging" in str(exc.value)

@pytest.mark.parametrize(
"metadata_key",
["metadata", "litellm_metadata"],
Expand Down
Loading
Loading