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
2 changes: 1 addition & 1 deletion litellm/proxy/response_api_endpoints/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _parse_cursor_model_variant(model: str) -> _CursorModelVariant:
def _router_can_serve(model: str, llm_router: "Router | None") -> bool:
if llm_router is None:
return False
if model in llm_router.model_names or model in llm_router.model_group_alias:
if llm_router.is_recognized_model(model):
return True
if model in llm_router.team_public_model_names:
return True
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
173 changes: 160 additions & 13 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 @@ -608,15 +609,19 @@ def __init__(
self.team_public_model_names: frozenset[str] = frozenset()

# Initialize cache attributes that ``_invalidate_model_group_info_cache``
# touches *before* the first ``set_model_list`` below (which calls
# that invalidation as part of building the model index).
# and ``_invalidate_access_groups_cache`` touch *before* the first
# ``set_model_list`` below (which calls those invalidations as part of
# building the model index) and before ``_init_routing_groups(None)``
# (which calls them on every group rebuild).
self._access_groups_cache: dict[str, list[str]] | None = None
# Per-router cache for the proxy auth-layer "is this model explicitly
# zero-cost?" check. Lives on the router so it is invalidated alongside
# ``_cached_get_model_group_info`` and dies with the router (no
# ``id()``-reuse risk after GC). See
# ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
self._zero_cost_cache: dict[str, bool] = {}
self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None
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 +1044,8 @@ 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.
self._invalidate_access_groups_cache()

if not groups_input:
return
Expand All @@ -1053,6 +1060,12 @@ 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 or group.group_name in (self.model_group_alias or {}):
verbose_router_logger.warning(
"routing_groups: group_name '%s' is shadowed by an existing model_name or model_group_alias; "
"the group's strategy still applies to its members, but the name is not callable until renamed.",
group.group_name,
)
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 +1102,82 @@ 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`.
"""
if not self._routing_groups:
return None
group: Final = self._routing_groups.get(model_name)
if (
group is None
or model_name in self.model_name_to_deployment_indices
or model_name in (self.model_group_alias or {})
):
Comment thread
cursor[bot] marked this conversation as resolved.
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.
"""
if not self._routing_groups:
return None
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.

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 +1238,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.
`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 +1253,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 +7234,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 +8418,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 +10074,52 @@ 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`.
"""
if model_name is not None:
group: Final = self.get_routing_group(model_name)
return self._materialize_routing_group_rows((group,)) if group is not None else ()
cached: Final = self._routing_group_rows
if cached is not None:
return cached
rows: Final = self._materialize_routing_group_rows(
tuple(
callable_group
for name in self._routing_groups
if (callable_group := self.get_routing_group(name)) is not None
)
)
self._routing_group_rows = rows
return rows

def _materialize_routing_group_rows(self, groups: tuple[RoutingGroup, ...]) -> tuple[DeploymentTypedDict, ...]:
return tuple(
self._as_routing_group_row(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)
)

@staticmethod
def _as_routing_group_row(deployment: DeploymentTypedDict) -> DeploymentTypedDict:
"""
A member deployment re-emitted under its group's name must not carry
the member's `access_groups`: access groups grant member names, never
the group, so inheriting them here would let a key holding a member's
access group list and call the whole group.
"""
model_info: Final = { # mutable-ok: DeploymentTypedDict rows are plain dicts
k: v for k, v in (deployment.get("model_info") or {}).items() if k != "access_groups"
}
return {**deployment, "model_info": model_info} # mutable-ok: DeploymentTypedDict rows are plain dicts

def get_model_list(
self, model_name: str | None = None, team_id: str | None = None
) -> list[DeploymentTypedDict] | None:
Expand All @@ -9997,6 +10136,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.
Comment thread
cursor[bot] marked this conversation as 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 @@ -10028,6 +10168,7 @@ def _invalidate_model_group_info_cache(self) -> None:
"""
self._cached_get_model_group_info.cache_clear()
self._zero_cost_cache.clear()
self._routing_group_rows = None

def _invalidate_access_groups_cache(self) -> None:
"""Invalidate the cached access groups.
Expand Down Expand Up @@ -10598,17 +10739,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
25 changes: 25 additions & 0 deletions tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,12 @@ def _router_serving_only(base_model: str) -> MagicMock:
mock_router.model_names = set()
mock_router.model_group_alias = {}
mock_router.team_public_model_names = frozenset()
mock_router.is_recognized_model.side_effect = lambda model: (
model in mock_router.model_names or model in mock_router.model_group_alias
)
mock_router.router_general_settings.pass_through_all_models = False
mock_router.default_deployment = None
mock_router.pattern_router.patterns = {base_model: ["anthropic/*"]}
mock_router.pattern_router.get_pattern.side_effect = (
lambda model: [{"model_name": "anthropic/*"}] if model == base_model else None
)
Expand Down Expand Up @@ -1723,3 +1729,22 @@ def test_auth_sees_servable_model_name_untouched(self):
)
assert auth_body["model"] == "claude-opus-5-thinking-high"
assert "reasoning_effort" not in auth_body


class TestCursorGateRecognizesRoutingGroups:
def test_group_name_variant_is_not_mangled(self):
from litellm import Router
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant

router = Router(
model_list=[
{"model_name": "member-fast", "litellm_params": {"model": "openai/gpt-4o", "api_key": "fake"}}
],
routing_groups=[
{"group_name": "grouped-thinking-high", "models": ["member-fast"], "routing_strategy": "simple-shuffle"}
],
)
body = {"model": "grouped-thinking-high", "messages": [{"role": "user", "content": "hi"}]}
resolved = _resolve_cursor_model_variant(body, router)
assert resolved["model"] == "grouped-thinking-high"
assert "reasoning_effort" not in resolved
Loading
Loading