diff --git a/litellm/router.py b/litellm/router.py index 80e8eb0c090..54b4d04a0cb 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -176,6 +176,8 @@ from litellm.router_utils.common_utils import ( _is_proxy_admin_request, filter_team_based_models, filter_web_search_deployments, + format_fallback_outcome_message, + format_no_fallback_group_message, get_request_team_id, provider_for_generic_call, resolve_model_group_alias, @@ -7162,6 +7164,9 @@ class Router: # behind the router name, and fallbacks are configured per tier, not per router. lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group) fallback_failure_exception_str = "" + no_fallback_group_explained = False + hop_depth: Final = kwargs.get("fallback_depth") + nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0 if disable_fallbacks is True or original_model_group is None: raise e @@ -7353,8 +7358,13 @@ class Router: " -> ".join(lookup_groups), masked_fallbacks, ) - if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors: - original_exception.message += f"No fallback model group found for lookup_groups={' -> '.join(lookup_groups)}. Fallbacks={masked_fallbacks}" + if ( + hasattr(original_exception, "message") + and litellm.expose_router_debug_in_errors + and not nested_fallback_hop + ): + original_exception.message += format_no_fallback_group_message(lookup_groups, fallbacks) + no_fallback_group_explained = True raise original_exception input_kwargs.update( @@ -7385,11 +7395,16 @@ class Router: cooldown_info, ) - if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors: - # add the available fallbacks to the exception - original_exception.message += f". Received Model Group={model_group}\nAvailable Model Group Fallbacks={mask_sensitive_structure(fallback_model_group)}" - if len(fallback_failure_exception_str) > 0: - original_exception.message += f"\nError doing the fallback: {fallback_failure_exception_str}" + attempted_fallback_group: Final = input_kwargs.get("fallback_model_group") + if ( + hasattr(original_exception, "message") + and litellm.expose_router_debug_in_errors + and not no_fallback_group_explained + and not nested_fallback_hop + ): + original_exception.message += format_fallback_outcome_message( + model_group, attempted_fallback_group, fallback_failure_exception_str + ) raise original_exception diff --git a/litellm/router_utils/common_utils.py b/litellm/router_utils/common_utils.py index 49cca8ee99e..7d111a80264 100644 --- a/litellm/router_utils/common_utils.py +++ b/litellm/router_utils/common_utils.py @@ -1,6 +1,6 @@ import hashlib import json -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Final @@ -12,6 +12,7 @@ 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 @@ -76,6 +77,36 @@ def truncate_fallback_error_detail(detail: str) -> str: return f"{detail[:ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS]}... [truncated {dropped} characters]" +def format_no_fallback_group_message(lookup_groups: Sequence[str], fallbacks: Sequence[Mapping[str, object]]) -> str: + """User-facing explanation appended when a request fails and no fallback chain matches its model group.""" + requested: Final = " -> ".join(lookup_groups) + configured: Final = tuple(dict.fromkeys(key for entry in fallbacks for key in entry)) + configured_text: Final = ( + f" Fallbacks are configured for: {', '.join(configured)}." if configured else " No fallbacks are configured." + ) + return ( + f"\n\nLiteLLM: model group '{requested}' failed with the error above and no fallback model group was found " + f"for it, so the request was not retried on another model.{configured_text}" + " Add a fallbacks entry for that model group (Router fallbacks or proxy router_settings.fallbacks)" + " to retry on another model." + ) + + +def format_fallback_outcome_message( + model_group: str | None, + fallback_model_group: Sequence[object] | None, + fallback_failure_detail: str, +) -> str: + """User-facing explanation appended when the fallback orchestrator gives up and re-raises the primary error.""" + 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) + 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}" + + def get_litellm_params_sensitive_credential_hash(litellm_params: dict) -> str: """ Hash of the credential params, used for mapping the file id to the right model diff --git a/tests/test_litellm/test_router_exception_redaction.py b/tests/test_litellm/test_router_exception_redaction.py index 6754775db22..5b52496fc1c 100644 --- a/tests/test_litellm/test_router_exception_redaction.py +++ b/tests/test_litellm/test_router_exception_redaction.py @@ -26,8 +26,8 @@ Five leak sites are gated in `litellm/router.py`: 1. Deployment timeout debug after `litellm.Timeout` 2. ContextWindowExceededError fallback hint 3. ContentPolicyViolationError fallback hint -4. "No fallback model group found for..." when fallbacks dict misses -5. "Received Model Group=...\\nAvailable Model Group Fallbacks=..." +4. "no fallback model group was found" when fallbacks dict misses +5. "model group '...' failed with the error above" plus the fallback outcome (always fires on terminal raise from the fallback orchestrator) Site 5 is the broadest — it fires for every failing call that goes @@ -42,8 +42,9 @@ import pytest import litellm from litellm import Router -_RECEIVED_MODEL_GROUP_PHRASE = "Received Model Group=" -_AVAILABLE_FALLBACKS_PHRASE = "Available Model Group Fallbacks=" +_RECEIVED_MODEL_GROUP_PHRASE = "failed with the error above" +_AVAILABLE_FALLBACKS_PHRASE = "No fallback was attempted" +_NO_FALLBACK_GROUP_PHRASE = "no fallback model group was found" _CONTEXT_WINDOW_HINT_PHRASE = "context_window_fallbacks=" _INTERNAL_MODEL_GROUP_NAME = "all-anthropic/claude-secret-internal" _FALLBACK_CREDENTIAL = "sk-INLINEFALLBACKSECRET1234567890" @@ -124,7 +125,7 @@ def test_flag_defaults_on(): assert litellm.expose_router_debug_in_errors is True -# --- Site 5: "Received Model Group=..." on terminal raise -------------------- +# --- Site 5: fallback outcome on terminal raise -------------------- @pytest.mark.asyncio @@ -192,7 +193,7 @@ async def test_flag_on_shows_context_window_fallback_hint(monkeypatch: pytest.Mo assert _INTERNAL_MODEL_GROUP_NAME in msg, msg -# --- Site 4: "No fallback model group found..." when fallbacks miss --------- +# --- Site 4: "no fallback model group was found" when fallbacks miss --------- @pytest.mark.asyncio @@ -221,7 +222,7 @@ async def test_flag_off_does_not_leak_when_no_fallback_group_found(monkeypatch: messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "No fallback model group found" not in msg, msg + assert _NO_FALLBACK_GROUP_PHRASE not in msg, msg assert "some-other-group" not in msg, msg assert _INTERNAL_MODEL_GROUP_NAME not in msg, msg @@ -250,8 +251,12 @@ async def test_flag_on_shows_when_no_fallback_group_found(monkeypatch: pytest.Mo messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "No fallback model group found" in msg, msg - assert _INTERNAL_MODEL_GROUP_NAME in msg, msg + assert _NO_FALLBACK_GROUP_PHRASE in msg, msg + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallbacks are configured for: some-other-group" in msg, msg + assert "not retried on another model" in msg, msg + assert _AVAILABLE_FALLBACKS_PHRASE not in msg, msg + assert msg.count("failed with the error above") == 1, msg # --- Site 1: Deployment timeout debug on litellm.Timeout -------------------- @@ -349,6 +354,30 @@ async def test_flag_on_shows_content_policy_fallback_hint(monkeypatch: pytest.Mo assert _INTERNAL_MODEL_GROUP_NAME in msg, msg +@pytest.mark.asyncio +async def test_flag_on_explains_failed_content_policy_fallback(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "expose_router_debug_in_errors", True) + router = Router( + model_list=[ + {"model_name": _INTERNAL_MODEL_GROUP_NAME, "litellm_params": {"model": "gpt-4o", "api_key": "key"}}, + {"model_name": "policy-safe-group", "litellm_params": {"model": "gpt-4o", "api_key": "key"}}, + ], + content_policy_fallbacks=[{_INTERNAL_MODEL_GROUP_NAME: ["policy-safe-group"]}], + num_retries=0, + ) + with pytest.raises(litellm.ContentPolicyViolationError) as excinfo: + await router.acompletion( + model=_INTERNAL_MODEL_GROUP_NAME, + messages=[{"role": "user", "content": "hi"}], + mock_response=_content_policy_error(), + ) + msg = excinfo.value.message + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallback to policy-safe-group also failed: " in msg, msg + assert _AVAILABLE_FALLBACKS_PHRASE not in msg, msg + assert msg.count("failed with the error above") == 1, msg + + # --- Credential masking: raw provider keys never leak, either flag state ---- @@ -387,7 +416,7 @@ async def test_flag_on_masks_fallback_credentials(monkeypatch: pytest.MonkeyPatc async def test_flag_on_scrubs_credential_from_inner_fallback_exception_string(monkeypatch: pytest.MonkeyPatch): """If the fallback attempt itself raises an exception whose message embeds a raw provider credential (e.g. a provider SDK echoing back the api_key it was - called with), that string is re-embedded via `Error doing the fallback: ...` + called with), that string is re-embedded via `Fallback to ... also failed: ...` on the terminal raise. The router must scrub known secret patterns from it. The primary fails with a benign rate-limit; the fallback deployment fails with an exception whose text contains the secret.""" @@ -423,6 +452,9 @@ async def test_flag_on_scrubs_credential_from_inner_fallback_exception_string(mo messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "Error doing the fallback:" in msg, msg + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallback to fallback-group also failed: " in msg, msg + assert "content_filter_policy" in msg, msg + assert msg.count("failed with the error above") == 1, msg assert inner_secret not in msg, msg assert "REDACTED" in msg, msg