From 12f2b3c03d21cb39f42e0c36fe6ac9e7af2c384c Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 30 Oct 2026 00:47:32 -0700 Subject: [PATCH] fix(logging): take the scrubbed exception message off the traceback when the payload has none When the failure payload could not be built, scrubbed_exception_text found the marker only in the logged traceback and handed Langfuse, Traceloop, OTel v1 and MLflow an empty message. Use the exception lines that end the scrubbed traceback. --- litellm/litellm_core_utils/redact_messages.py | 13 +++++++++---- .../litellm_core_utils/test_redact_messages.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index d3ae03b5650..358b2e6d72c 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -583,6 +583,7 @@ def scrubbed_exception_text(model_call_details: Mapping[str, object]) -> tuple[s """ The error message and traceback a failure logged when redaction scrubbed the request's text out of either, for callbacks that would otherwise record the raw exception. ``None`` when nothing was scrubbed. + Without a logged error message, the message is the exception text that ends the traceback. """ payload: Final = _as_mapping(model_call_details.get("standard_logging_object")) or {} error_information: Final = _as_mapping(payload.get("error_information")) or {} @@ -593,10 +594,14 @@ def scrubbed_exception_text(model_call_details: Mapping[str, object]) -> tuple[s ) if not any(isinstance(text, str) and REDACTED_BY_LITELLM in text for text in (message, traceback_text)): return None - return ( - message if isinstance(message, str) else "", - traceback_text if isinstance(traceback_text, str) else "", - ) + logged_traceback_text: Final = traceback_text if isinstance(traceback_text, str) else "" + return (message if isinstance(message, str) else _exception_text(logged_traceback_text), logged_traceback_text) + + +def _exception_text(traceback_text: str) -> str: + """The lines after the last indented frame line of ``traceback_text``.""" + lines: Final = reversed(traceback_text.rstrip().splitlines()) + return "\n".join(reversed(tuple(itertools.takewhile(lambda line: not line.startswith(" "), lines)))) def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]: diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 0d466a94149..632957d08ee 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -2202,6 +2202,21 @@ def test_scrubbed_exception_text_reports_a_scrub_of_the_logged_traceback() -> No assert scrubbed_exception_text(model_call_details) == ("outer", "ValueError: bad: redacted-by-litellm") +def test_scrubbed_exception_text_reads_the_message_off_the_traceback_without_a_logged_message() -> None: + traceback_text: Final = ( + "Traceback (most recent call last):\n" + ' File "main.py", line 1, in completion\n' + " raise BadRequestError(message)\n" + "BadRequestError: Invalid content: 'redacted-by-litellm'\n" + "Received Messages=['redacted-by-litellm']\n" + ) + + assert scrubbed_exception_text({"traceback_exception": traceback_text}) == ( + "BadRequestError: Invalid content: 'redacted-by-litellm'\nReceived Messages=['redacted-by-litellm']", + traceback_text, + ) + + def test_scrubbed_error_information_for_request_scrubs_only_when_the_request_redacts() -> None: error_information: Final = StandardLoggingPayloadErrorInformation( error_code="400", error_class="BadRequestError", error_message=f"bad: {PROMPT}", traceback=f"tb {PROMPT}"