Skip to content
10 changes: 1 addition & 9 deletions litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,7 @@ async def chat_completion_pass_through_endpoint(
# skip router if user passed their key
if "api_key" in data:
llm_response = asyncio.create_task(litellm.aadapter_completion(**data))
elif llm_router is not None and data["model"] in router_model_names: # model in router model list
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
elif (
llm_router is not None
and llm_router.model_group_alias is not None
and data["model"] in llm_router.model_group_alias
): # model set in model_group_alias
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
elif llm_router is not None and llm_router.has_model_id(data["model"]): # model in router model list
elif llm_router is not None and llm_router.is_recognized_model(data["model"]):
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
elif (
llm_router is not None
Expand Down
14 changes: 4 additions & 10 deletions litellm/proxy/route_llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,16 +587,10 @@ async def route_request(
return getattr(llm_router, f"{route_type}")(**data)

elif (
(
is_proxy_admin_without_team
and data["model"] not in router_model_names
and data["model"] in llm_router.team_public_model_names
)
or data["model"] in router_model_names
or llm_router.has_model_id(data["model"])
or llm_router.model_group_alias is not None
and data["model"] in llm_router.model_group_alias
):
is_proxy_admin_without_team
and data["model"] not in router_model_names
and data["model"] in llm_router.team_public_model_names
) or llm_router.is_recognized_model(data["model"]):
return getattr(llm_router, f"{route_type}")(**data)

elif data["model"] not in router_model_names:
Expand Down
134 changes: 123 additions & 11 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
coerce_token_limit,
get_litellm_metadata_from_kwargs,
get_metadata_variable_name_from_kwargs,
get_or_create_metadata_bucket,
)
Expand Down Expand Up @@ -617,6 +618,7 @@ def __init__(
# ``id()``-reuse risk after GC). See
# ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
self._zero_cost_cache: dict[str, bool] = {}
self._init_routing_groups(None)

self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
self.model_group_affinity_config = model_group_affinity_config
Expand Down Expand Up @@ -1039,6 +1041,7 @@ def _init_routing_groups(
self._routing_groups: dict[str, RoutingGroup] = {}
self._model_to_group: dict[str, str] = {}
self._group_selectors: dict[str, dict[str, RouterStrategySelector]] = {}
self._invalidate_model_group_info_cache()
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if not groups_input:
return
Expand All @@ -1053,6 +1056,15 @@ def _init_routing_groups(
raise ValueError("routing_groups: group_name must be non-empty.")
if group.group_name == "default":
raise ValueError("routing_groups: 'default' is reserved for the implicit fallback group.")
if group.group_name in known_model_names:
raise ValueError(
f"routing_groups: group_name '{group.group_name}' collides with an existing model_name; "
"group names are callable models and must not shadow one."
)
if group.group_name in self.model_group_alias:
raise ValueError(
f"routing_groups: group_name '{group.group_name}' collides with a model_group_alias entry."
)
if group.group_name in seen_group_names:
raise ValueError(
f"routing_groups: group names must be unique, duplicate group_name '{group.group_name}'."
Expand Down Expand Up @@ -1089,6 +1101,74 @@ def _init_routing_groups(
{strategy_value: group_selector} if group_selector is not None else {}
)

def get_routing_group(self, model_name: str) -> RoutingGroup | None:
"""
The routing group callable as `model_name`, or None. A real deployment
`model_name` added after init shadows a same-named group (mirroring
`_try_early_resolve_deployments_for_model_not_in_names`, where concrete
models win over indirection); config-time collisions are rejected by
`_init_routing_groups`.
"""
group: Final = self._routing_groups.get(model_name)
if group is None or model_name in self.model_name_to_deployment_indices:
return None
Comment on lines +1115 to +1121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deleted shadow remains active

When a runtime deployment whose model name matches a routing group is added and later deleted, its name remains in self.model_names, so get_routing_group continues suppressing the group and requests and discovery cannot use it until the model list is rebuilt.

Knowledge Base Used: Router: deployment selection, retries, fallbacks, and cooldowns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8cbadcc: model_names is now maintained on deletion at the removal-repair owner, healing the other stale consumers too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8cbadcc: model_names is now maintained on deletion at the removal-repair owner, healing the other stale consumers too

return group

def _get_routing_group_deployments(
self, model: str, team_id: str | None = None
) -> list[DeploymentTypedDict] | None: # mutable-ok: list matches _get_all_deployments' contract for callers
"""
The union of member deployments for a routing group called as `model`,
or None when `model` is not a callable group. The requested name stays
the group name so strategy selectors key their state by it.

`_common_checks_available_deployment` consults this BEFORE its
early-resolve step so a wildcard `default_deployment` or pattern route
cannot hijack a group call. Overall resolution precedence there:
specific deployment > model id > model_group_alias > routing group >
model_name > team/pattern/default fallbacks.
"""
routing_group: Final = self.get_routing_group(model)
if routing_group is None:
return None
return [ # mutable-ok: matches _get_all_deployments' list contract expected by downstream filters
deployment
for member in routing_group.models
for deployment in self._get_all_deployments(model_name=member, team_id=team_id)
]

def is_recognized_model(self, model: str) -> bool:
"""
Whether `model` names something this router serves directly: a
deployment model_name, a deployment id, a `model_group_alias`, or a
callable routing group. Proxy request gates share this predicate so a
new virtual-model kind cannot be forgotten at one of them; wildcard,
default-deployment, and deployment-name fallbacks stay caller policy.
"""
return (
model in self.model_names
or self.has_model_id(model)
or (self.model_group_alias is not None and model in self.model_group_alias)
or self.get_routing_group(model) is not None
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

def routing_group_has_alternatives(self, model_group: str | None) -> bool:
"""
True when `model_group` names a callable routing group whose member
union spans more than one deployment. Cooldown handling passes the
FAILING REQUEST's model group here: a 429 on a group call cools the
member down so selection moves to the group's alternatives, while a
direct call to a single-deployment member keeps the
single-deployment-model-group cooldown exemption.
"""
if model_group is None:
return False
resolved: Final = self._get_model_from_alias(model=model_group) or model_group
group: Final = self.get_routing_group(resolved)
if group is None:
return False
return sum(len(self.model_name_to_deployment_indices.get(member) or ()) for member in group.models) > 1

_OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY})

def _get_request_routing_strategy_override(self, request_kwargs: dict | None) -> str | None:
Expand Down Expand Up @@ -1149,8 +1229,10 @@ def _get_routing_context(
the most specific expression of caller intent.

Otherwise every model belongs to exactly one group: an explicit entry
from `routing_groups`, or the implicit `"default"` group driven by the
router's top-level `routing_strategy` / `routing_strategy_args`.
from `routing_groups` (either because `model` IS a callable group name,
or because it is a member of one), or the implicit `"default"` group
driven by the router's top-level `routing_strategy` /
`routing_strategy_args`.

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
`self.routing_strategy` may be either a string or a `RoutingStrategy`
enum member (the constructor accepts both), so it is normalized to a
Expand All @@ -1162,7 +1244,7 @@ def _get_routing_context(
verbose_router_logger.debug("routing_group=request-override model=%s strategy=%s", model, override)
return override, self._get_override_strategy_selector(override)

group_name: Final = self._model_to_group.get(model)
group_name: Final = model if self.get_routing_group(model) is not None else self._model_to_group.get(model)
if group_name is None:
strategy = self._normalize_strategy(self.routing_strategy)
attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
Expand Down Expand Up @@ -7143,6 +7225,7 @@ def deployment_callback_on_failure(
original_exception=exception,
deployment=deployment_id,
time_to_cooldown=_time_to_cooldown,
requested_model_group=(get_litellm_metadata_from_kwargs(kwargs) or {}).get("model_group"),
) # setting deployment_id in cooldown deployments

return result
Expand Down Expand Up @@ -8326,6 +8409,7 @@ def _update_deployment_indices_after_removal(self, model_id: str, removal_idx: i
self.model_name_to_deployment_indices[model_name] = updated_indices
else:
del self.model_name_to_deployment_indices[model_name]
self.model_names.discard(model_name)

# Update team_model_to_deployment_indices
for key, indices in list(self.team_model_to_deployment_indices.items()):
Expand Down Expand Up @@ -9981,6 +10065,27 @@ def get_model_list_from_model_alias(self, model_name: str | None = None) -> list

return returned_models

def get_model_list_from_routing_groups(self, model_name: str | None = None) -> Sequence[DeploymentTypedDict]:
"""
Callable routing groups materialized as model-list rows, mirroring
`get_model_list_from_model_alias`: each member deployment is emitted
under the group's name (via `_get_all_deployments`' `model_alias`
rewrite), which is what surfaces groups in `get_model_names`,
`/v1/models` discovery, `get_model_group_usage`, and the
blocked/unhealthy hiding that all read `get_model_list`.
"""
groups: Final = (
tuple(group for group in (self.get_routing_group(model_name),) if group is not None)
if model_name is not None
else tuple(group for name in self._routing_groups if (group := self.get_routing_group(name)) is not None)
)
return tuple(
deployment
for group in groups
for member in group.models
for deployment in self._get_all_deployments(model_name=member, model_alias=group.group_name)
)

def get_model_list(
self, model_name: str | None = None, team_id: str | None = None
) -> list[DeploymentTypedDict] | None:
Expand All @@ -9997,6 +10102,7 @@ def get_model_list(
returned_models.extend(self._get_all_deployments(model_name=model_name, team_id=team_id))

returned_models.extend(self.get_model_list_from_model_alias(model_name=model_name))
returned_models.extend(self.get_model_list_from_routing_groups(model_name=model_name))
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if len(returned_models) == 0: # check if wildcard route
potential_wildcard_models: Final = self.pattern_router.route(model_name) or []
Expand Down Expand Up @@ -10598,17 +10704,23 @@ def _common_checks_available_deployment(
if _model_from_alias is not None:
model = _model_from_alias

early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
model=model,
request_team_id=request_team_id,
include_team_models=_is_proxy_admin_request(request_kwargs),
)
if early is not None:
return early
_routing_group_deployments: Final = self._get_routing_group_deployments(model=model, team_id=request_team_id)
if _routing_group_deployments is None:
early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
model=model,
request_team_id=request_team_id,
include_team_models=_is_proxy_admin_request(request_kwargs),
)
if early is not None:
return early

## get healthy deployments
### get all deployments
healthy_deployments = self._get_all_deployments(model_name=model, team_id=request_team_id)
healthy_deployments = (
_routing_group_deployments
if _routing_group_deployments is not None
else self._get_all_deployments(model_name=model, team_id=request_team_id)
)
_pre_model_access_group_filter_len: Final = len(healthy_deployments)
healthy_deployments = self._filter_deployments_by_model_access_groups(
model=model,
Expand Down
7 changes: 6 additions & 1 deletion litellm/router_utils/cooldown_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ def _should_cooldown_deployment(
deployment: str,
exception_status: str | int,
original_exception: Any,
requested_model_group: str | None = None,
) -> bool:
"""
Helper that decides if a deployment should be put in cooldown
Expand All @@ -341,7 +342,9 @@ def _should_cooldown_deployment(
model_group: Final = litellm_router_instance.get_model_group(id=deployment)
is_single_deployment_model_group = False
if model_group is not None and len(model_group) == 1:
is_single_deployment_model_group = True
is_single_deployment_model_group = not litellm_router_instance.routing_group_has_alternatives(
requested_model_group
)
Comment on lines 344 to +347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Direct-member cooldown behavior breaks

When a direct request to a single-deployment member receives a 429 or crosses the failure threshold, routing-group alternatives disable its single-deployment exemption even though those alternatives are unavailable to the direct request, causing subsequent calls to that member to fail with no healthy deployment until cooldown expires.

Knowledge Base Used: Router: deployment selection, retries, fallbacks, and cooldowns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8cbadcc: cooldown now keys on the failing request's model group, so direct member calls keep the exemption

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8cbadcc: cooldown now keys on the failing request's model group, so direct member calls keep the exemption


## CHECK DEPLOYMENT-LEVEL POLICY FIRST (overrides router-level)
dep_policy, dep_allowed_fails = _get_deployment_cooldown_policy(litellm_router_instance, deployment)
Expand Down Expand Up @@ -413,6 +416,7 @@ def _set_cooldown_deployments(
exception_status: str | int,
deployment: str | None = None,
time_to_cooldown: float | None = None,
requested_model_group: str | None = None,
) -> bool:
"""
Add a model to the list of models being cooled down for that minute, if it exceeds the allowed fails / minute
Expand Down Expand Up @@ -449,6 +453,7 @@ def _set_cooldown_deployments(
deployment=deployment,
exception_status=exception_status,
original_exception=original_exception,
requested_model_group=requested_model_group,
):
litellm_router_instance.cooldown_cache.add_deployment_to_cooldown(
model_id=deployment,
Expand Down
24 changes: 24 additions & 0 deletions tests/test_litellm/proxy/test_route_llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,3 +1091,27 @@ async def test_route_request_rejects_chat_completion_without_messages():
assert exc_info.value.status_code == 400
assert exc_info.value.param == "messages"
llm_router.acompletion.assert_not_called()


@pytest.mark.asyncio
async def test_route_request_routing_group_name_passes_model_gate():
from unittest.mock import AsyncMock, patch

from litellm import Router

router = Router(
model_list=[
{"model_name": "member-a", "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test"}},
{"model_name": "member-b", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}},
],
routing_groups=[
{"group_name": "grouped-quality", "models": ["member-a", "member-b"], "routing_strategy": "simple-shuffle"}
],
)
data = {"model": "grouped-quality", "messages": [{"role": "user", "content": "hi"}]}

with patch.object(router, "acompletion", new=AsyncMock(return_value="group_response")) as spy:
response = await (await route_request(data, router, None, "acompletion"))

assert response == "group_response"
spy.assert_called_once_with(**data)
Loading
Loading