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