From 95865a76253a9701e47d04299db8001d52266daf Mon Sep 17 00:00:00 2001 From: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:28:20 -0700 Subject: [PATCH] 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. --- litellm/litellm_core_utils/litellm_logging.py | 2 +- litellm/litellm_core_utils/redact_messages.py | 91 ++++++++++++++----- .../test_redact_messages.py | 59 ++++++++++++ 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 715e492b159..1f29082de08 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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( diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 45ab93b50fc..12a4384efa7 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -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"] = "" diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 3c87a8cecb6..23243ae694f 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -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."""