From ca956b5477df3babc444d8f57471ad15003f6758 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 30 Jul 2026 00:25:51 -0700 Subject: [PATCH] fix(cache_warming): read the caller's redaction opt-out from its owner should_redact_message_logging takes a model_call_details dict whose required shape is implicit: the header and global forms are read off litellm_params, but the per-request form is read from standard_callback_dynamic_params at the top level. Capture passed only litellm_params, so a caller setting turn_off_message_logging in the request body had its opt-out silently dropped and its prompts retained in Redis anyway; the two forms that happened to be tested, headers and the global setting, both worked, which is why the gap survived. The gate's own docstring claimed the per-request form was honored. The value is owned by the logging object, which initializes it from the request in its constructor, so it is read off the object rather than re-derived, and the key is spelled exactly as the request path spells it when it builds the same dict for the same predicate. Where no logging object rides the request (SDK-direct use) the behavior is unchanged, and those callers consent through cache_warming.enabled itself. The matrix test already named a per-request case but exercised the header leg, so it now drives all three body spellings (root, metadata, litellm_metadata) through a real Logging object built the way function_setup builds it, plus a no-opt-out control proving the same path still captures. The three body cases fail without this change and the control does not. --- .../cache_warming/capture.py | 19 ++++++- .../cache_warming/test_capture.py | 49 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/litellm/router_strategy/complexity_router/cache_warming/capture.py b/litellm/router_strategy/complexity_router/cache_warming/capture.py index 9c7bb3f8f5f..35088409338 100644 --- a/litellm/router_strategy/complexity_router/cache_warming/capture.py +++ b/litellm/router_strategy/complexity_router/cache_warming/capture.py @@ -61,12 +61,27 @@ def _capture_allowed(kwargs: Mapping[str, object]) -> bool: retained? Honors both halves of the operator's stated policy: the redaction opt-out (turn_off_message_logging, including per-request and header forms) and the prompt-retention opt-in (store_prompts_in_spend_logs; SDK use without the - proxy consents through cache_warming.enabled itself).""" + proxy consents through cache_warming.enabled itself). + + should_redact_message_logging reads a model_call_details dict whose shape is + implicit: the header and global forms come off litellm_params, but the + per-request form is read from standard_callback_dynamic_params at the TOP level, + so passing only litellm_params silently loses the caller's own opt-out. That + value is owned by the logging object, which initializes it from the request in + its constructor, so it is read off the object here rather than re-derived; the + key is spelled exactly as the request path spells it when it builds the same + dict for the same predicate (litellm_logging.py:565).""" from litellm.litellm_core_utils.redact_messages import ( should_redact_message_logging, # pyright: ignore[reportUnknownVariableType] # legacy-untyped helper ) - if should_redact_message_logging({"litellm_params": kwargs}): # mutable-ok: read-only view for the predicate + model_call_details = { # mutable-ok: read-only view for the predicate + "litellm_params": kwargs, + "standard_callback_dynamic_params": getattr( + kwargs.get("litellm_logging_obj"), "standard_callback_dynamic_params", None + ), + } + if should_redact_message_logging(model_call_details): return False try: from litellm.proxy.spend_tracking.spend_tracking_utils import ( diff --git a/tests/test_litellm/router_strategy/complexity_router/cache_warming/test_capture.py b/tests/test_litellm/router_strategy/complexity_router/cache_warming/test_capture.py index 4e3929290ae..c4e8e7b2564 100644 --- a/tests/test_litellm/router_strategy/complexity_router/cache_warming/test_capture.py +++ b/tests/test_litellm/router_strategy/complexity_router/cache_warming/test_capture.py @@ -66,6 +66,25 @@ def _kwargs(**overrides: object) -> dict: return {**base, **overrides} +def _logging_obj(kwargs: dict) -> object: + """The real Logging object the proxy threads into deployment selection, built from the request the way + function_setup builds it, so the dynamic params under test are the ones production would carry.""" + from datetime import datetime + + from litellm.litellm_core_utils.litellm_logging import Logging + + return Logging( + model=str(kwargs.get("model")), + messages=MESSAGES, + stream=False, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="capture-test", + function_id="capture-test", + kwargs=kwargs, + ) + + def _stored_records(redis: FakeRedisCache) -> list[dict]: return [json.loads(value) for value in redis.hashes.get(SESSIONS_KEY, {}).values()] @@ -144,10 +163,24 @@ async def test_second_turn_overwrites_payload_and_preserves_other_model_warmth() @pytest.mark.asyncio -@pytest.mark.parametrize("opt_out", ["no-retention-consent", "global-redaction", "per-request-redaction"]) +@pytest.mark.parametrize( + "opt_out", + [ + "no-retention-consent", + "global-redaction", + "redaction-header", + "per-request-body-root", + "per-request-body-metadata", + "per-request-body-litellm-metadata", + None, + ], +) async def test_capture_honors_every_form_of_the_operators_prompt_retention_policy(opt_out, monkeypatch): """Capture persists full prompts, so it requires the retention opt-in and respects message redaction in - every form it can be expressed.""" + every form it can be expressed. The per-request cases carry a real Logging object because the caller's own + turn_off_message_logging is read from the dynamic params it owns rather than from the request body, so a + stub would assert the wiring instead of the policy. The final case is the negative control: with no opt-out + expressed anywhere, this same path must still capture, otherwise the gate proves nothing.""" redis = FakeRedisCache() router = _complexity_router(redis) kwargs = _kwargs() @@ -156,10 +189,18 @@ async def test_capture_honors_every_form_of_the_operators_prompt_retention_polic monkeypatch.delenv("STORE_PROMPTS_IN_SPEND_LOGS", raising=False) elif opt_out == "global-redaction": monkeypatch.setattr(litellm, "turn_off_message_logging", True) - else: + elif opt_out == "redaction-header": kwargs["metadata"]["headers"] = {"x-litellm-enable-message-redaction": True} + elif opt_out == "per-request-body-root": + kwargs["turn_off_message_logging"] = True + elif opt_out == "per-request-body-metadata": + kwargs["metadata"]["turn_off_message_logging"] = True + elif opt_out == "per-request-body-litellm-metadata": + kwargs["litellm_metadata"] = {"session_id": "sess-1", "turn_off_message_logging": True} + kwargs["litellm_logging_obj"] = _logging_obj(kwargs) await router._capture_session(kwargs, MESSAGES, "claude-sonnet-4-5") - assert redis.hashes.get(SESSIONS_KEY, {}) == {} + stored = redis.hashes.get(SESSIONS_KEY, {}) + assert stored == {} if opt_out is not None else stored != {} @pytest.mark.asyncio