mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
fix(router): walk every entry of a fallback list after a mid-stream failure
A fallback hop that dies before its first chunk surfaces inside the streaming iterator, where the chain lookup is keyed by the hop's own group. That group has no chain of its own, so the remaining entries of the original list were never tried. Resume the original group's chain as the last lookup key; attempted_targets already skips the entries that were tried. Resolves LIT-7400 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
7cb884cd31
commit
86960fb127
3 changed files with 92 additions and 3 deletions
|
|
@ -307,13 +307,19 @@ def fallbacks_disabled_for_request(kwargs: Mapping[str, Any]) -> bool:
|
|||
def fallback_lookup_groups(kwargs: Mapping[str, object], model_group: str | None) -> tuple[str, ...]:
|
||||
"""
|
||||
Ordered keys for resolving a fallback chain: the tier a pre-routing hook selected wins,
|
||||
then the routed group, then the requested group. The routed group differs when Claude Code
|
||||
session affinity remaps a subagent's concrete model to its bound router.
|
||||
then the routed group, then the requested group, then the group the request was
|
||||
originally for. The routed group differs when Claude Code session affinity remaps a
|
||||
subagent's concrete model to its bound router. The original group differs on a fallback
|
||||
hop that fails after `run_async_fallback` already returned its stream: the hop has no
|
||||
chain of its own, so it resumes the original group's chain, and `attempted_targets` keeps
|
||||
the entries already tried from being repeated.
|
||||
"""
|
||||
metadata: Final = kwargs.get(get_metadata_variable_name_from_kwargs(kwargs))
|
||||
routed_group_value: Final = metadata.get("model_group") if isinstance(metadata, Mapping) else None
|
||||
routed_group: Final = routed_group_value if isinstance(routed_group_value, str) else None
|
||||
ordered: Final = (get_pre_routing_selection(kwargs), routed_group, model_group)
|
||||
original_group_value: Final = metadata.get("original_model_group") if isinstance(metadata, Mapping) else None
|
||||
original_group: Final = original_group_value if isinstance(original_group_value, str) else None
|
||||
ordered: Final = (get_pre_routing_selection(kwargs), routed_group, model_group, original_group)
|
||||
return tuple(dict.fromkeys(group for group in ordered if group))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1305,6 +1305,14 @@ class TestOrderedFallbackLookupGroups:
|
|||
"requested-model",
|
||||
)
|
||||
|
||||
def test_fallback_hop_resumes_the_original_groups_chain_last(self):
|
||||
from litellm.router_utils.fallback_event_handlers import fallback_lookup_groups
|
||||
|
||||
kwargs = {"metadata": {"model_group": "fb1", "original_model_group": "primary"}}
|
||||
|
||||
assert fallback_lookup_groups(kwargs, "fb1") == ("fb1", "primary")
|
||||
assert fallback_lookup_groups({"metadata": {"original_model_group": 42}}, "fb1") == ("fb1",)
|
||||
|
||||
def test_first_resolving_group_wins_and_generic_idx_survives_a_miss(self):
|
||||
from litellm.router_utils.fallback_event_handlers import (
|
||||
get_fallback_model_group_for_lookup_groups,
|
||||
|
|
|
|||
|
|
@ -3447,6 +3447,81 @@ def test_completion_streaming_iterator_adopts_the_deployment_that_served_a_neste
|
|||
assert result._hidden_params["model_id"] == "served-deployment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_mid_stream_fallback_walks_every_entry_of_the_configured_list():
|
||||
"""LIT-7400: fallbacks=[{primary: [fb1, fb2]}] must reach fb2 when fb1 dies before its first chunk.
|
||||
|
||||
run_async_fallback returns as soon as fb1's stream wrapper exists, so fb1's failure surfaces
|
||||
inside the streaming iterator, where the lookup is keyed by fb1. That key has no chain of its
|
||||
own, so the iterator has to resume the chain of the group the request was originally for.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from litellm.exceptions import MidStreamFallbackError
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
|
||||
attempted_model_groups: list[str] = []
|
||||
|
||||
class FailingStream(CustomStreamWrapper):
|
||||
def __init__(self, model: str):
|
||||
super().__init__(
|
||||
completion_stream=object(), model=model, custom_llm_provider="openai", logging_obj=MagicMock()
|
||||
)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise MidStreamFallbackError(
|
||||
message=f"provider 500 from {self.model}",
|
||||
model=self.model,
|
||||
llm_provider="openai",
|
||||
generated_content="",
|
||||
is_pre_first_chunk=True,
|
||||
original_exception=litellm.InternalServerError(
|
||||
message=f"provider 500 from {self.model}", model=self.model, llm_provider="openai"
|
||||
),
|
||||
)
|
||||
|
||||
class OkStream(FailingStream):
|
||||
def __init__(self, model: str):
|
||||
super().__init__(model)
|
||||
self._chunks = iter(
|
||||
[litellm.ModelResponseStream(choices=[{"index": 0, "delta": {"content": f"ok-from-{model}"}}])]
|
||||
)
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._chunks)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration from None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
attempted_model_groups.append(kwargs["metadata"]["model_group"])
|
||||
if "fb2" in kwargs["model"]:
|
||||
return OkStream(kwargs["model"])
|
||||
return FailingStream(kwargs["model"])
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{"model_name": "primary", "litellm_params": {"model": "openai/primary-model", "api_key": "fake-key"}},
|
||||
{"model_name": "fb1", "litellm_params": {"model": "openai/fb1-model", "api_key": "fake-key"}},
|
||||
{"model_name": "fb2", "litellm_params": {"model": "openai/fb2-model", "api_key": "fake-key"}},
|
||||
],
|
||||
fallbacks=[{"primary": ["fb1", "fb2"]}],
|
||||
num_retries=0,
|
||||
)
|
||||
|
||||
with patch("litellm.acompletion", side_effect=fake_acompletion):
|
||||
response = await router.acompletion(model="primary", messages=[{"role": "user", "content": "hi"}], stream=True)
|
||||
content: Final = "".join(
|
||||
[chunk.choices[0].delta.content or "" async for chunk in response if chunk is not None]
|
||||
)
|
||||
|
||||
assert content == "ok-from-openai/fb2-model"
|
||||
assert attempted_model_groups == ["primary", "fb1", "fb2"]
|
||||
|
||||
|
||||
def test_completion_streaming_iterator_adopts_fallback_response_headers():
|
||||
"""LIT-6767, sync counterpart of the fallback-adoption test."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue