From 4ec5a6761c014ab4b3316d25a469b234b5e88876 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:58:33 -0700 Subject: [PATCH 1/2] fix(proxy): redact provider keys from pass-through failure tracebacks A failed pass-through call logged the httpx traceback, whose message quotes the upstream URL with the provider API key in its query string, into the spend log's error information and into every failure callback. The error information built for logging now redacts its traceback and error message, and the traceback is redacted once before the failure callbacks receive it. --- litellm/litellm_core_utils/litellm_logging.py | 4 +- litellm/proxy/utils.py | 4 +- .../test_litellm_logging.py | 29 +++++++++++ tests/test_litellm/proxy/test_proxy_utils.py | 49 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c31c4323157..989777e9412 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5675,8 +5675,8 @@ class StandardLoggingPayloadSetup: error_code=error_status, error_class=error_class, llm_provider=_llm_provider_in_exception, - traceback=traceback_info, - error_message=error_message, + traceback=_redact_string(traceback_info), + error_message=_redact_string(error_message), error_rate_limit_category=rate_limit_category, error_rate_limit_type=rate_limit_type, error_budget_entity_type=budget_error.entity_type if budget_error else None, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index accf7b720fb..b4e4dfeae67 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -2568,6 +2568,8 @@ class ProxyLogging: # Remove before callbacks iterate — not serialisable request_data.pop("litellm_logging_obj", None) + redacted_traceback_str: Final = _redact_string(traceback_str) if traceback_str is not None else None + # Track the first HTTPException returned or raised by any callback transformed_exception: HTTPException | None = None @@ -2586,7 +2588,7 @@ class ProxyLogging: request_data=request_data, user_api_key_dict=user_api_key_dict, original_exception=original_exception, - traceback_str=traceback_str, + traceback_str=redacted_traceback_str, ) # If callback returned an HTTPException, use it (first one wins) if isinstance(hook_result, HTTPException) and transformed_exception is None: diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 16a99713a06..0f44cbbb2a7 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -6457,3 +6457,32 @@ def test_get_status_fields_ranks_guardrail_flagged_between_success_and_intervene assert _get_status_fields( "success", [flagged, {"guardrail_status": "guardrail_intervened"}], None )["guardrail_status"] == "guardrail_intervened" + + +def test_get_error_information_redacts_provider_key_from_upstream_url(): + """A pass-through upstream failure logs the httpx traceback, whose message + quotes the upstream URL with the provider key in its query string. That + key must never reach spend logs or logging callbacks.""" + import traceback + + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + provider_key = "AIza" + "S" * 35 + upstream_url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent?key={provider_key}" + response = httpx.Response(400, request=httpx.Request("POST", upstream_url)) + try: + response.raise_for_status() + except httpx.HTTPStatusError as caught: + upstream_error = caught + upstream_traceback = traceback.format_exc() + assert provider_key in upstream_traceback + + result = StandardLoggingPayloadSetup.get_error_information( + original_exception=upstream_error, traceback_str=upstream_traceback + ) + + assert provider_key not in result["traceback"] + assert provider_key not in result["error_message"] + assert "REDACTED" in result["traceback"] + assert "REDACTED" in result["error_message"] + assert result["error_code"] == "400" diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index f16c6c937d0..51176c2b251 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -6,6 +6,7 @@ from fastapi import HTTPException from litellm.caching.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import ProxyErrorTypes from litellm.proxy.utils import ProxyLogging from litellm.types.guardrails import GuardrailEventHooks @@ -1919,3 +1920,51 @@ async def test_proxy_only_error_5xx_keeps_traceback_and_runs_sync_callbacks(monk Logging.failure_handler = orig_sync_failure assert "test_proxy_utils" in captured["async_traceback"] + + +class _TracebackRecordingLogger(CustomLogger): + def __init__(self): + super().__init__() + self.received_traceback: str | None = None + + async def async_post_call_failure_hook(self, request_data, original_exception, user_api_key_dict, traceback_str=None): + self.received_traceback = traceback_str + return None + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_redacts_traceback_before_callbacks(monkeypatch): + """A pass-through upstream failure hands the hook the httpx traceback, whose + message quotes the upstream URL with the provider key in its query string. + Every callback, custom loggers included, must receive it redacted.""" + import traceback + from unittest.mock import AsyncMock, patch + + import httpx + + from litellm.proxy._types import UserAPIKeyAuth + + provider_key = "AIza" + "S" * 35 + upstream_url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent?key={provider_key}" + response = httpx.Response(400, request=httpx.Request("POST", upstream_url)) + try: + response.raise_for_status() + except httpx.HTTPStatusError: + upstream_traceback = traceback.format_exc() + assert provider_key in upstream_traceback + + recorder = _TracebackRecordingLogger() + monkeypatch.setattr(litellm, "callbacks", [recorder]) + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + proxy_logging_obj.alert_types = [] + with patch.object(proxy_logging_obj, "update_request_status", new=AsyncMock()): + await proxy_logging_obj.post_call_failure_hook( + request_data={"metadata": {}}, + original_exception=HTTPException(status_code=400, detail="Upstream passthrough request failed with status 400"), + user_api_key_dict=UserAPIKeyAuth(), + traceback_str=upstream_traceback, + ) + + assert recorder.received_traceback is not None + assert provider_key not in recorder.received_traceback + assert "REDACTED" in recorder.received_traceback From 27c55a21d98627661703de9683a64d6718841824 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:19:29 -0700 Subject: [PATCH 2/2] test(proxy): type the traceback-recording hook to match CustomLogger The regression test's recording logger overrode async_post_call_failure_hook with untyped parameters. It now mirrors the base signature, and the UserAPIKeyAuth import moves to module level so the annotation resolves. --- tests/test_litellm/proxy/test_proxy_utils.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 51176c2b251..dcf18e773e8 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from litellm.caching.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy._types import ProxyErrorTypes +from litellm.proxy._types import ProxyErrorTypes, UserAPIKeyAuth from litellm.proxy.utils import ProxyLogging from litellm.types.guardrails import GuardrailEventHooks @@ -1923,11 +1923,17 @@ async def test_proxy_only_error_5xx_keeps_traceback_and_runs_sync_callbacks(monk class _TracebackRecordingLogger(CustomLogger): - def __init__(self): + def __init__(self) -> None: super().__init__() self.received_traceback: str | None = None - async def async_post_call_failure_hook(self, request_data, original_exception, user_api_key_dict, traceback_str=None): + async def async_post_call_failure_hook( + self, + request_data: dict, + original_exception: Exception, + user_api_key_dict: UserAPIKeyAuth, + traceback_str: str | None = None, + ) -> HTTPException | None: self.received_traceback = traceback_str return None @@ -1942,8 +1948,6 @@ async def test_post_call_failure_hook_redacts_traceback_before_callbacks(monkeyp import httpx - from litellm.proxy._types import UserAPIKeyAuth - provider_key = "AIza" + "S" * 35 upstream_url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent?key={provider_key}" response = httpx.Response(400, request=httpx.Request("POST", upstream_url))