fix(router): explain fallback outcome in plain words in the raised error (#42509)

* fix(router): explain fallback outcome in plain words in the raised error

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): keep the fallback outcome trailer on the outermost hop only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): explain failed context-window and content-policy fallbacks too

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): name both Router and proxy fallback config in the no-fallback hint

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 18:37:42 +00:00 • committed by GitHub
parent e0c2cbff21
commit a788c4ab2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 19 deletions

View file

@ -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

View file

@ -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

View file

@ -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