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.
This commit is contained in:
Tin Chi Lo 2026-07-30 00:25:51 -07:00
parent e5c84d6acf
commit f5014e99a9
2 changed files with 62 additions and 6 deletions

View file

@ -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 (

View file

@ -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