mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
e5c84d6acf
commit
f5014e99a9
2 changed files with 62 additions and 6 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue