mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
fix(logging): keep shared request body for per-callback redaction and drop hidden provider fields
The deprecated per-callback message_logging=False path ran perform_redaction on the shared model_call_details, which now also redacted the nested proxy_server_request body. The spend log Request column and previous_response_id replay read that body, so one opted-out callback blanked them for everyone. That path now keeps the shared body and hidden params, and redact_streaming_responses_for_custom_logger hands the opted-out callback its own redacted litellm_params. Redacted litellm_params also clear provider_specific_fields copied into metadata.hidden_params (Anthropic thinking text), in litellm_params and in the nested request body, and the nested body redacts system and instructions.
This commit is contained in:
parent
4a581a0470
commit
95865a7625
3 changed files with 130 additions and 22 deletions
|
|
@ -3530,7 +3530,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
if isinstance(callback, CustomLogger): # custom logger class
|
||||
from litellm.integrations.shadow_eval_logger import ShadowEvalLogger
|
||||
|
||||
model_call_details: dict = self.model_call_details
|
||||
model_call_details: dict[str, object] = self.model_call_details
|
||||
##################################
|
||||
# call redaction hook for custom logger
|
||||
model_call_details = callback.redact_standard_logging_payload_from_model_call_details(
|
||||
|
|
|
|||
|
|
@ -44,26 +44,37 @@ else:
|
|||
LiteLLMLoggingObject = Any
|
||||
|
||||
|
||||
def _message_logging_disabled(custom_logger: CustomLogger) -> bool:
|
||||
return hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True
|
||||
|
||||
|
||||
def redact_message_input_output_from_custom_logger(
|
||||
litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger
|
||||
):
|
||||
if hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True:
|
||||
if _message_logging_disabled(custom_logger):
|
||||
return perform_redaction(litellm_logging_obj.model_call_details, result, redact_streaming_responses=False)
|
||||
return result
|
||||
|
||||
|
||||
def redact_streaming_responses_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict:
|
||||
def redact_streaming_responses_for_custom_logger(
|
||||
model_call_details: dict[str, object], custom_logger: CustomLogger
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Returns a copy of model_call_details whose streaming response entries are redacted deepcopies
|
||||
when the custom logger has opted out of message logging. The shared model_call_details is left
|
||||
untouched so other callbacks still receive the unredacted response.
|
||||
and whose litellm_params carry the redacted proxy request body when the custom logger has opted
|
||||
out of message logging. The shared model_call_details is left untouched so other callbacks and
|
||||
the spend log still receive the unredacted request and response.
|
||||
"""
|
||||
if not (hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True):
|
||||
if not _message_logging_disabled(custom_logger):
|
||||
return model_call_details
|
||||
params: Final = model_call_details.get("litellm_params")
|
||||
redacted_entries: Final = {
|
||||
streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key])
|
||||
for streaming_key in ("complete_streaming_response", "async_complete_streaming_response")
|
||||
if model_call_details.get(streaming_key) is not None
|
||||
**{
|
||||
streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key])
|
||||
for streaming_key in ("complete_streaming_response", "async_complete_streaming_response")
|
||||
if model_call_details.get(streaming_key) is not None
|
||||
},
|
||||
**({"litellm_params": redacted_litellm_params(params)} if isinstance(params, Mapping) else {}),
|
||||
}
|
||||
if not redacted_entries:
|
||||
return model_call_details
|
||||
|
|
@ -268,39 +279,75 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
|
|||
_redact_choice_content(choice)
|
||||
|
||||
|
||||
def _redacted_baseline_metadata(metadata: Mapping[str, object]) -> Mapping[str, object]:
|
||||
def _hidden_params_without_provider_fields(hidden_params: object) -> Mapping[object, object] | None:
|
||||
"""Copy of ``hidden_params`` with ``provider_specific_fields`` cleared, or None when it carries none."""
|
||||
if not (isinstance(hidden_params, Mapping) and "provider_specific_fields" in hidden_params):
|
||||
return None
|
||||
return {**_REQUEST_BODY.validate_python(hidden_params), "provider_specific_fields": None}
|
||||
|
||||
|
||||
def _redacted_metadata(metadata: Mapping[str, object], *, redact_content: bool) -> Mapping[str, object]:
|
||||
route: Final = metadata.get("_autorouter_baseline_route")
|
||||
if not isinstance(route, BaselineRouteStamp):
|
||||
return metadata
|
||||
return {**metadata, "_autorouter_baseline_route": replace(route, request_parameters=None)}
|
||||
hidden_params: Final = (
|
||||
_hidden_params_without_provider_fields(metadata.get("hidden_params")) if redact_content else None
|
||||
)
|
||||
redacted_entries: Final[Mapping[str, object]] = {
|
||||
**(
|
||||
{"_autorouter_baseline_route": replace(route, request_parameters=None)}
|
||||
if isinstance(route, BaselineRouteStamp)
|
||||
else {}
|
||||
),
|
||||
**({"hidden_params": hidden_params} if hidden_params is not None else {}),
|
||||
}
|
||||
return {**metadata, **redacted_entries} if redacted_entries else metadata
|
||||
|
||||
|
||||
def _redacted_proxy_request(request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
def _without_hidden_provider_fields(metadata: Mapping[object, object]) -> Mapping[object, object]:
|
||||
hidden_params: Final = _hidden_params_without_provider_fields(metadata.get("hidden_params"))
|
||||
return metadata if hidden_params is None else {**metadata, "hidden_params": hidden_params}
|
||||
|
||||
|
||||
def _redacted_proxy_request(request: Mapping[str, object], *, redact_content: bool) -> Mapping[str, object]:
|
||||
raw_body: Final = request.get("body")
|
||||
if not isinstance(raw_body, Mapping):
|
||||
if not (redact_content and isinstance(raw_body, Mapping)):
|
||||
return without_classifier_audit(request)
|
||||
body: Final = _REQUEST_BODY.validate_python(raw_body)
|
||||
return {
|
||||
**without_classifier_audit(request),
|
||||
"body": {
|
||||
**body,
|
||||
**{
|
||||
key: _without_hidden_provider_fields(_REQUEST_BODY.validate_python(value))
|
||||
for key, value in body.items()
|
||||
if key in ("metadata", "litellm_metadata") and isinstance(value, Mapping)
|
||||
},
|
||||
**({"messages": [{"role": "user", "content": REDACTED_BY_LITELLM}]} if "messages" in body else {}),
|
||||
**({"prompt": ""} if "prompt" in body else {}),
|
||||
**({"input": ""} if "input" in body else {}),
|
||||
**{key: REDACTED_BY_LITELLM for key in ("system", "instructions") if body.get(key) is not None},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def redacted_litellm_params(params: Mapping[str, object]) -> dict[str, object]:
|
||||
def redacted_litellm_params(params: Mapping[str, object], *, redact_content: bool = True) -> dict[str, object]:
|
||||
"""
|
||||
Copy of litellm_params without classifier audit fields or baseline route request parameters.
|
||||
redact_content also redacts the nested proxy request body and the provider-specific fields
|
||||
copied into ``hidden_params``.
|
||||
"""
|
||||
request: Final = params.get("proxy_server_request")
|
||||
return {
|
||||
**params,
|
||||
**{
|
||||
key: _redacted_baseline_metadata(value)
|
||||
key: _redacted_metadata(value, redact_content=redact_content)
|
||||
for key, value in params.items()
|
||||
if key in ("metadata", "litellm_metadata") and isinstance(value, Mapping)
|
||||
},
|
||||
**({"proxy_server_request": _redacted_proxy_request(request)} if isinstance(request, Mapping) else {}),
|
||||
**(
|
||||
{"proxy_server_request": _redacted_proxy_request(request, redact_content=redact_content)}
|
||||
if isinstance(request, Mapping)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -308,14 +355,16 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
|
|||
"""
|
||||
Performs the actual redaction on the logging object and result.
|
||||
|
||||
redact_streaming_responses=False skips replacing the shared streaming
|
||||
response entries; per-callback redaction hands each opted-out callback its own redacted
|
||||
copy via redact_streaming_responses_for_custom_logger instead.
|
||||
redact_streaming_responses=False is the per-callback path: it leaves the shared streaming
|
||||
response entries, proxy request body and hidden provider fields in place, and each opted-out
|
||||
callback gets its own redacted copy via redact_streaming_responses_for_custom_logger instead.
|
||||
"""
|
||||
# Redact model_call_details
|
||||
params: Final = model_call_details.get("litellm_params")
|
||||
if isinstance(params, Mapping):
|
||||
model_call_details["litellm_params"] = redacted_litellm_params(params)
|
||||
model_call_details["litellm_params"] = redacted_litellm_params(
|
||||
params, redact_content=redact_streaming_responses
|
||||
)
|
||||
model_call_details["messages"] = [{"role": "user", "content": REDACTED_BY_LITELLM}]
|
||||
model_call_details["prompt"] = ""
|
||||
model_call_details["input"] = ""
|
||||
|
|
|
|||
|
|
@ -99,6 +99,65 @@ def test_streaming_provider_reasoning_is_redacted_only_in_callback_copy(key: str
|
|||
assert details[key]._hidden_params["opaque"] is response._hidden_params["opaque"]
|
||||
|
||||
|
||||
def _request_with_hidden_reasoning(secret: str) -> tuple[dict, dict]:
|
||||
hidden_params: Final = {
|
||||
"additional_headers": {"x-ratelimit-remaining-requests": "99"},
|
||||
"provider_specific_fields": {"thinking_blocks": [{"type": "thinking", "thinking": secret}]},
|
||||
}
|
||||
metadata: Final = {"session_id": "session", "hidden_params": hidden_params}
|
||||
body: Final = {
|
||||
"model": "redaction-model",
|
||||
"system": secret,
|
||||
"instructions": secret,
|
||||
"messages": [{"role": "user", "content": secret}],
|
||||
"metadata": metadata,
|
||||
"litellm_metadata": metadata,
|
||||
}
|
||||
params: Final = {
|
||||
"metadata": metadata,
|
||||
"litellm_metadata": metadata,
|
||||
"proxy_server_request": {"url": "http://localhost/v1/messages", "body": body},
|
||||
}
|
||||
return params, hidden_params
|
||||
|
||||
|
||||
def test_redaction_removes_reasoning_copied_into_hidden_params_and_request_body() -> None:
|
||||
secret: Final = "private-hidden-reasoning"
|
||||
params, hidden_params = _request_with_hidden_reasoning(secret)
|
||||
snapshot: Final = copy.deepcopy(params)
|
||||
details: Final = {"litellm_params": params}
|
||||
|
||||
perform_redaction(details, None)
|
||||
|
||||
redacted: Final = details["litellm_params"]
|
||||
assert secret not in str(redacted)
|
||||
body: Final = redacted["proxy_server_request"]["body"]
|
||||
assert body["system"] == "redacted-by-litellm"
|
||||
assert body["instructions"] == "redacted-by-litellm"
|
||||
assert body["model"] == "redaction-model"
|
||||
for metadata in (redacted["metadata"], redacted["litellm_metadata"], body["metadata"], body["litellm_metadata"]):
|
||||
assert metadata["session_id"] == "session"
|
||||
assert metadata["hidden_params"]["provider_specific_fields"] is None
|
||||
assert metadata["hidden_params"]["additional_headers"] == hidden_params["additional_headers"]
|
||||
assert params == snapshot
|
||||
|
||||
|
||||
def test_per_callback_redaction_keeps_shared_request_body_for_other_readers() -> None:
|
||||
secret: Final = "private-shared-request"
|
||||
params, _ = _request_with_hidden_reasoning(secret)
|
||||
snapshot: Final = copy.deepcopy(params)
|
||||
details: Final[dict[str, object]] = {"litellm_params": params, "messages": [{"role": "user", "content": secret}]}
|
||||
opted_out_logger: Final = CustomLogger(message_logging=False)
|
||||
|
||||
perform_redaction(details, None, redact_streaming_responses=False)
|
||||
callback_details: Final = redact_streaming_responses_for_custom_logger(details, opted_out_logger)
|
||||
|
||||
assert details["litellm_params"] == snapshot
|
||||
assert details["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
|
||||
assert secret not in str(callback_details["litellm_params"])
|
||||
assert redact_streaming_responses_for_custom_logger(details, CustomLogger()) is details
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_global_redaction():
|
||||
"""Ensure the global setting is off for every test."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue