From 27620d903a6e45b2be1c9a23f25c95f94de79333 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 25 Sep 2026 11:54:30 -0700 Subject: [PATCH] fix(router): name only the model of each fallback target in the raised error Client-side fallbacks can be raw param dicts carrying messages, and the fallback outcome trailer printed them whole, so prompt text reached the error message, logs, and alerts. Key-name masking never covered content. --- litellm/router_utils/common_utils.py | 13 +++++++++-- tests/unit/test_router_exception_redaction.py | 22 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/litellm/router_utils/common_utils.py b/litellm/router_utils/common_utils.py index 7d111a80264..b1bd2783a9a 100644 --- a/litellm/router_utils/common_utils.py +++ b/litellm/router_utils/common_utils.py @@ -12,7 +12,6 @@ from litellm._logging import verbose_logger, verbose_router_logger from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider -from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure from litellm.types.router import CredentialLiteLLMParams from litellm.types.utils import LlmProviders @@ -92,6 +91,16 @@ def format_no_fallback_group_message(lookup_groups: Sequence[str], fallbacks: Se ) +def _fallback_target_label(target: object) -> str: + match target: + case str(): + return target + case {"model": str() as model}: + return model + case _: + return "" + + def format_fallback_outcome_message( model_group: str | None, fallback_model_group: Sequence[object] | None, @@ -101,7 +110,7 @@ def format_fallback_outcome_message( lead: Final = f"\n\nLiteLLM: model group '{model_group}' failed with the error above." if not fallback_model_group: return f"{lead} No fallback was attempted." - targets: Final = ", ".join(str(mask_sensitive_structure(target)) for target in fallback_model_group) + targets: Final = ", ".join(_fallback_target_label(target) for target in fallback_model_group) if not fallback_failure_detail: return f"{lead} Fallback model group(s) configured: {targets}." return f"{lead} Fallback to {targets} also failed: {fallback_failure_detail}" diff --git a/tests/unit/test_router_exception_redaction.py b/tests/unit/test_router_exception_redaction.py index 5b52496fc1c..ef68b0d50e0 100644 --- a/tests/unit/test_router_exception_redaction.py +++ b/tests/unit/test_router_exception_redaction.py @@ -405,11 +405,25 @@ async def test_flag_on_masks_fallback_credentials(monkeypatch: pytest.MonkeyPatc messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - # The raw credential must never appear, even though debug exposure is on assert _FALLBACK_CREDENTIAL not in msg, msg - # The fallback wiring is still shown (masking preserves structure, it does - # not drop the whole message), so the api_key key name survives - assert "api_key" in msg, msg + assert "Fallback to gpt-4o also failed: " in msg, msg + assert "api_key" not in msg, msg + + +@pytest.mark.asyncio +async def test_flag_on_names_only_the_model_of_client_side_fallback_dicts(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "expose_router_debug_in_errors", True) + fallback_prompt = "Patient Jane Doe, SSN 123-45-6789" + router = _router_with_rate_limit_failure() + with pytest.raises(litellm.RateLimitError) as excinfo: + await router.acompletion( + model=_INTERNAL_MODEL_GROUP_NAME, + messages=[{"role": "user", "content": "hi"}], + fallbacks=[{"model": "missing-fallback-model", "messages": [{"role": "user", "content": fallback_prompt}]}], + ) + msg = excinfo.value.message + assert "Fallback to missing-fallback-model also failed: " in msg, msg + assert fallback_prompt not in msg, msg @pytest.mark.asyncio