From 1f2b7bdce33d2af18fd463a47f6d86826c0d3748 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Wed, 30 Sep 2026 01:09:41 +0800 Subject: [PATCH 1/8] fix(router): skip unsatisfiable tag-routing fallback legs Before attempting a fallback model group, skip legs that cannot satisfy confirmed tag_routing_prefix tags. Structurally impossible single-deployment chain links no longer raise, log ERROR, or inflate deployment failure metrics when a later leg in the same chain can serve the request. Fixes #43736 --- litellm/router_strategy/tag_based_routing.py | 74 ++++++++ .../router_utils/fallback_event_handlers.py | 26 +++ .../test_fallback_event_handlers.py | 176 ++++++++++++++++++ 3 files changed, 276 insertions(+) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index 50dce250920..f34e2193144 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -693,3 +693,77 @@ def _get_tags_from_request_kwargs( typed_litellm_params: Final[Mapping[str, object]] = litellm_params return _tags_in_metadata(typed_litellm_params.get(resolved_variable_name)) return [] + + +def can_satisfy_confirmed_routing_tags( + llm_router_instance: LitellmRouter, + model: str, + request_kwargs: Mapping[str, object] | None = None, + metadata_variable_name: Literal["metadata", "litellm_metadata"] | None = None, +) -> bool: + """ + Whether attempting ``model`` can possibly succeed under the request's + ``tag_routing_prefix``-confirmed tags. + + Returns False only when confirmed routing tags make success structurally + impossible for every deployment in the model group — so + ``run_async_fallback`` can skip that leg without raising, logging ERROR, or + counting a deployment failure. Returns True when the check does not apply + (no prefix, no confirmed tags, tag filtering off) or the group might still + serve the request. + """ + routing_prefix: Final = getattr(llm_router_instance, "tag_routing_prefix", None) or "" + if not routing_prefix or request_kwargs is None: + return True + + request_tags: Final = _get_tags_from_request_kwargs(request_kwargs, metadata_variable_name) + rewritten_tags, routing_confirmed = _strip_routing_prefix(request_tags, routing_prefix) + if not routing_confirmed: + return True + + try: + deployments: Final = llm_router_instance._get_all_deployments(model_name=model) + except Exception: # noqa: BLE001 # fail safe toward attempting the leg on lookup errors + return True + if not deployments: + return True + + request_enable_tag_filtering: Final = request_kwargs.get("enable_tag_filtering") + chain_enable_tag_filtering: Final = _chain_tag_filtering_override(llm_router_instance, model, deployments) + router_enable_tag_filtering: Final = getattr(llm_router_instance, "enable_tag_filtering", False) + chain_default: Final = ( + chain_enable_tag_filtering if chain_enable_tag_filtering is not None else router_enable_tag_filtering + ) + if request_enable_tag_filtering is not True and chain_default is not True: + return True + + required_tags, positive_tags, excluded_patterns = _split_tags(rewritten_tags) + excluded_set: Final = frozenset(excluded_patterns) + required_set: Final = frozenset(required_tags) + candidates: Final = _require_all_tags(_exclude_deployments(deployments, excluded_set), required_set) + + confirmed_required: Final = required_set & routing_confirmed + confirmed_excluded: Final = excluded_set & routing_confirmed + if not candidates and (confirmed_required or confirmed_excluded): + return False + + confirmed_positive: Final = [tag for tag in positive_tags if tag in routing_confirmed] + if not confirmed_positive: + return True + + # A tag_regex deployment may still match via request headers even when plain + # tags do not; leave those legs to the normal attempt path. + if any(d.get("litellm_params", MappingProxyType({})).get("tag_regex") for d in candidates): + return True + + # Confirmed positive tags force a hard deny when nothing matches (defaults / + # fail-open do not apply). Skip the leg rather than attempting it. + match_any: Final = getattr(llm_router_instance, "tag_filtering_match_any", True) + return any( + is_valid_deployment_tag( + d.get("litellm_params", MappingProxyType({})).get("tags") or [], + positive_tags, + match_any, + ) + for d in candidates + ) diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index e0df7d1badf..68315c8e605 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -545,6 +545,30 @@ async def _is_fallback_target_within_budget( return False +def _is_fallback_target_tag_satisfiable( + litellm_router: LitellmRouter, + fallback_entry: str | Mapping[str, object], + kwargs: Mapping[str, object], +) -> bool: + """ + Skip a fallback leg whose model group cannot satisfy confirmed tag-routing + tags. Mirrors the authorized/budget pre-checks: structurally unsatisfiable + legs must not be attempted, raised, logged at ERROR, or counted as failures. + """ + target: Final = _get_fallback_target_model_group(fallback_entry) + if target is None: + return True + from litellm.router_strategy.tag_based_routing import can_satisfy_confirmed_routing_tags + + if can_satisfy_confirmed_routing_tags(litellm_router, target, kwargs): + return True + verbose_router_logger.info( + "Skipping fallback to model_group = %s: no deployment can satisfy confirmed tag routing tags", + mask_sensitive_structure(fallback_entry), + ) + return False + + def references_provider_scoped_resource(kwargs: Mapping[str, object]) -> bool: """ True when a file, batch, or fine-tuning job operation names an id that only exists @@ -654,6 +678,8 @@ async def run_async_fallback( continue if not await _is_fallback_target_within_budget(litellm_router, mg, original_model_group, kwargs): continue + if not _is_fallback_target_tag_satisfiable(litellm_router, mg, kwargs): + continue attempt_key = fallback_attempt_key(mg) if attempt_key is not None: if attempt_key in attempted: diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index 972c04b209f..c2847987c87 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -547,6 +547,182 @@ async def test_run_async_fallback_does_not_consult_access_check_for_same_model_g assert router.access_checks == [] + + +class TagRoutingFallbackRouter: + """Records fallback attempts while exposing tag-routing deployment lookups.""" + + fallback_access_check = None + fallback_budget_check = None + enable_tag_filtering = True + tag_routing_prefix = "route:" + tag_filtering_match_any = True + + def __init__(self, deployments_by_group: dict[str, list[dict]]): + self.deployments_by_group = deployments_by_group + self.attempted_model_groups = [] + + def log_retry(self, kwargs, e): + return kwargs + + def _get_all_deployments(self, model_name: str, **kwargs): + return list(self.deployments_by_group.get(model_name, [])) + + async def async_function_with_fallbacks(self, *args, **kwargs): + self.attempted_model_groups.append(kwargs.get("model")) + return StreamingWrapper() + + +@pytest.mark.asyncio +async def test_run_async_fallback_skips_legs_unsatisfiable_under_confirmed_tags(caplog): + """Confirmed tag_routing_prefix tags must skip structurally impossible legs at INFO, + not attempt them (which would raise/log ERROR and inflate failure metrics).""" + import logging + + router = TagRoutingFallbackRouter( + { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex", "default"]}, + } + ], + "chain/3-bedrock": [ + { + "model_name": "chain/3-bedrock", + "litellm_params": {"tags": ["inference:bedrock", "default"]}, + } + ], + } + ) + + with caplog.at_level(logging.INFO, logger="LiteLLM Router"): + response = await run_async_fallback( + litellm_router=router, + fallback_model_group=["chain/2-vertex", "chain/3-bedrock"], + original_model_group="chain/1-openai", + original_exception=RuntimeError("primary failed"), + max_fallbacks=5, + fallback_depth=0, + model="chain/1-openai", + metadata={"tags": ["route:inference:bedrock"]}, + ) + + assert router.attempted_model_groups == ["chain/3-bedrock"] + assert response._hidden_params["additional_headers"]["x-litellm-attempted-fallbacks"] == 1 + skip_logs = [r for r in caplog.records if "no deployment can satisfy confirmed tag routing tags" in r.getMessage()] + assert len(skip_logs) == 1 + assert skip_logs[0].levelno == logging.INFO + assert "chain/2-vertex" in skip_logs[0].getMessage() + + +@pytest.mark.asyncio +async def test_run_async_fallback_raises_original_when_all_legs_tag_unsatisfiable(): + router = TagRoutingFallbackRouter( + { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex", "default"]}, + } + ], + "chain/3-anthropic": [ + { + "model_name": "chain/3-anthropic", + "litellm_params": {"tags": ["inference:anthropic", "default"]}, + } + ], + } + ) + + with pytest.raises(RuntimeError, match="primary failed"): + await run_async_fallback( + litellm_router=router, + fallback_model_group=["chain/2-vertex", "chain/3-anthropic"], + original_model_group="chain/1-openai", + original_exception=RuntimeError("primary failed"), + max_fallbacks=5, + fallback_depth=0, + model="chain/1-openai", + metadata={"tags": ["route:inference:bedrock"]}, + ) + + assert router.attempted_model_groups == [] + + +@pytest.mark.asyncio +async def test_run_async_fallback_does_not_skip_when_tag_routing_prefix_unset(): + router = TagRoutingFallbackRouter( + { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex", "default"]}, + } + ], + } + ) + router.tag_routing_prefix = "" + + await run_async_fallback( + litellm_router=router, + fallback_model_group=["chain/2-vertex"], + original_model_group="chain/1-openai", + original_exception=RuntimeError("primary failed"), + max_fallbacks=3, + fallback_depth=0, + model="chain/1-openai", + metadata={"tags": ["route:inference:bedrock"]}, + ) + + assert router.attempted_model_groups == ["chain/2-vertex"] + + +def test_can_satisfy_confirmed_routing_tags_false_for_mismatched_single_deployment(): + from litellm.router_strategy.tag_based_routing import can_satisfy_confirmed_routing_tags + + router = TagRoutingFallbackRouter( + { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex", "default"]}, + } + ], + } + ) + assert ( + can_satisfy_confirmed_routing_tags( + router, + "chain/2-vertex", + {"metadata": {"tags": ["route:inference:bedrock"]}}, + ) + is False + ) + + +def test_can_satisfy_confirmed_routing_tags_true_when_deployment_matches(): + from litellm.router_strategy.tag_based_routing import can_satisfy_confirmed_routing_tags + + router = TagRoutingFallbackRouter( + { + "chain/3-bedrock": [ + { + "model_name": "chain/3-bedrock", + "litellm_params": {"tags": ["inference:bedrock", "default"]}, + } + ], + } + ) + assert ( + can_satisfy_confirmed_routing_tags( + router, + "chain/3-bedrock", + {"metadata": {"tags": ["route:inference:bedrock"]}}, + ) + is True + ) + class RecordingFailRouter: fallback_access_check = None fallback_budget_check = None From a3bda8bf652304121c652ba4bfcecec5c592c383 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Thu, 1 Oct 2026 10:03:50 -0700 Subject: [PATCH 2/8] fix(router): satisfy LIT002 in confirmed-tag fallback check Use tuple/() instead of list literals in can_satisfy_confirmed_routing_tags so the type-discipline gate stays within budget. --- litellm/router_strategy/tag_based_routing.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index f34e2193144..4bc76084504 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -747,21 +747,17 @@ def can_satisfy_confirmed_routing_tags( if not candidates and (confirmed_required or confirmed_excluded): return False - confirmed_positive: Final = [tag for tag in positive_tags if tag in routing_confirmed] + confirmed_positive: Final = tuple(tag for tag in positive_tags if tag in routing_confirmed) if not confirmed_positive: return True - # A tag_regex deployment may still match via request headers even when plain - # tags do not; leave those legs to the normal attempt path. if any(d.get("litellm_params", MappingProxyType({})).get("tag_regex") for d in candidates): return True - # Confirmed positive tags force a hard deny when nothing matches (defaults / - # fail-open do not apply). Skip the leg rather than attempting it. match_any: Final = getattr(llm_router_instance, "tag_filtering_match_any", True) return any( is_valid_deployment_tag( - d.get("litellm_params", MappingProxyType({})).get("tags") or [], + d.get("litellm_params", MappingProxyType({})).get("tags") or (), positive_tags, match_any, ) From b354793c52d1c3cf8fa50805ac914c820fdf689f Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sat, 3 Oct 2026 10:04:28 -0700 Subject: [PATCH 3/8] fix(router): type confirmed-tag fallback check for pyright budget Align can_satisfy_confirmed_routing_tags with existing deployment param typing so reportUnknownArgumentType stays within the gate. --- litellm/router_strategy/tag_based_routing.py | 31 ++++++++++--------- .../router_utils/fallback_event_handlers.py | 6 ++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index 4bc76084504..73fcdd6a1a7 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -10,7 +10,7 @@ Use this to route requests between Teams import re from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, overload +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, overload from litellm._logging import verbose_logger from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY, ROUTING_REQUEST_TAGS_METADATA_KEY @@ -712,7 +712,7 @@ def can_satisfy_confirmed_routing_tags( (no prefix, no confirmed tags, tag filtering off) or the group might still serve the request. """ - routing_prefix: Final = getattr(llm_router_instance, "tag_routing_prefix", None) or "" + routing_prefix: Final = llm_router_instance.tag_routing_prefix or "" if not routing_prefix or request_kwargs is None: return True @@ -722,7 +722,9 @@ def can_satisfy_confirmed_routing_tags( return True try: - deployments: Final = llm_router_instance._get_all_deployments(model_name=model) + deployments: Final[_DeploymentPool] = cast( + _DeploymentPool, llm_router_instance._get_all_deployments(model_name=model) + ) except Exception: # noqa: BLE001 # fail safe toward attempting the leg on lookup errors return True if not deployments: @@ -730,9 +732,10 @@ def can_satisfy_confirmed_routing_tags( request_enable_tag_filtering: Final = request_kwargs.get("enable_tag_filtering") chain_enable_tag_filtering: Final = _chain_tag_filtering_override(llm_router_instance, model, deployments) - router_enable_tag_filtering: Final = getattr(llm_router_instance, "enable_tag_filtering", False) chain_default: Final = ( - chain_enable_tag_filtering if chain_enable_tag_filtering is not None else router_enable_tag_filtering + chain_enable_tag_filtering + if chain_enable_tag_filtering is not None + else llm_router_instance.enable_tag_filtering ) if request_enable_tag_filtering is not True and chain_default is not True: return True @@ -751,15 +754,13 @@ def can_satisfy_confirmed_routing_tags( if not confirmed_positive: return True - if any(d.get("litellm_params", MappingProxyType({})).get("tag_regex") for d in candidates): + if any(d.get("litellm_params", {}).get("tag_regex") for d in candidates): return True - match_any: Final = getattr(llm_router_instance, "tag_filtering_match_any", True) - return any( - is_valid_deployment_tag( - d.get("litellm_params", MappingProxyType({})).get("tags") or (), - positive_tags, - match_any, - ) - for d in candidates - ) + match_any: Final = llm_router_instance.tag_filtering_match_any + for deployment in candidates: + litellm_params: Final = deployment.get("litellm_params", {}) + deployment_tags: Final[Sequence[str] | None] = litellm_params.get("tags") + if is_valid_deployment_tag(deployment_tags or (), positive_tags, match_any): + return True + return False diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index be539cde76c..91357a5ec13 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import litellm from litellm._logging import verbose_router_logger @@ -678,7 +678,9 @@ async def run_async_fallback( continue if not await _is_fallback_target_within_budget(litellm_router, mg, original_model_group, kwargs): continue - if not _is_fallback_target_tag_satisfiable(litellm_router, mg, kwargs): + if not _is_fallback_target_tag_satisfiable( + litellm_router, mg, cast(Mapping[str, object], kwargs) + ): continue attempt_key = fallback_attempt_key(mg) if attempt_key is not None: From 11337bfafe509949c6d083064b3eff4dcaab2fbf Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Mon, 5 Oct 2026 10:03:22 -0700 Subject: [PATCH 4/8] fix(router): fix lint and fallback test fakes for tag-satisfiable check can_satisfy_confirmed_routing_tags reads router.tag_routing_prefix directly, so the fake routers in test_fallback_event_handlers now carry the attribute alongside fallback_access_check / fallback_budget_check. Also keep the change inside the lint gates: ruff format the new call site, reuse _all_deployments_or_fallback instead of a second private _get_all_deployments lookup, drop Final from loop-bound names, and give the two casts their reasons for TID251 / LIT006. --- litellm/router_strategy/tag_based_routing.py | 21 +++++++------------ .../router_utils/fallback_event_handlers.py | 6 ++++-- .../test_fallback_event_handlers.py | 9 ++++++-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index 73fcdd6a1a7..c1dd459e1d1 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -10,7 +10,7 @@ Use this to route requests between Teams import re from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, overload +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, overload # noqa: TID251 # Router pool narrowing from litellm._logging import verbose_logger from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY, ROUTING_REQUEST_TAGS_METADATA_KEY @@ -721,12 +721,9 @@ def can_satisfy_confirmed_routing_tags( if not routing_confirmed: return True - try: - deployments: Final[_DeploymentPool] = cast( - _DeploymentPool, llm_router_instance._get_all_deployments(model_name=model) - ) - except Exception: # noqa: BLE001 # fail safe toward attempting the leg on lookup errors - return True + deployments: Final = cast( # cast-ok: [LIT006] router deployment dicts are read through _DeploymentLike + _DeploymentPool, _all_deployments_or_fallback(llm_router_instance, model, ()) + ) if not deployments: return True @@ -758,9 +755,7 @@ def can_satisfy_confirmed_routing_tags( return True match_any: Final = llm_router_instance.tag_filtering_match_any - for deployment in candidates: - litellm_params: Final = deployment.get("litellm_params", {}) - deployment_tags: Final[Sequence[str] | None] = litellm_params.get("tags") - if is_valid_deployment_tag(deployment_tags or (), positive_tags, match_any): - return True - return False + return any( + is_valid_deployment_tag(d.get("litellm_params", {}).get("tags") or (), positive_tags, match_any) + for d in candidates + ) diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 91357a5ec13..1c92b9a807f 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast # noqa: TID251 # narrows untyped **kwargs for the tag check import litellm from litellm._logging import verbose_router_logger @@ -679,7 +679,9 @@ async def run_async_fallback( if not await _is_fallback_target_within_budget(litellm_router, mg, original_model_group, kwargs): continue if not _is_fallback_target_tag_satisfiable( - litellm_router, mg, cast(Mapping[str, object], kwargs) + litellm_router, + mg, + cast(Mapping[str, object], kwargs), # cast-ok: [LIT006] **kwargs is untyped here ): continue attempt_key = fallback_attempt_key(mg) diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index c2847987c87..b9c10b29ac0 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -29,6 +29,7 @@ class StreamingWrapper: class FakeRouter: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def log_retry(self, kwargs, e): return kwargs @@ -40,6 +41,7 @@ class FakeRouter: class AlwaysFailRouter: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def log_retry(self, kwargs, e): return kwargs @@ -105,6 +107,7 @@ async def test_run_async_fallback_raises_when_all_fallbacks_fail(): class RecordingRouter: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def __init__(self): self.received_kwargs = None @@ -167,6 +170,7 @@ async def test_run_async_fallback_skips_original_model_group(): class AttemptRecordingRouter: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def __init__(self): self.attempted_model_groups = [] @@ -547,8 +551,6 @@ async def test_run_async_fallback_does_not_consult_access_check_for_same_model_g assert router.access_checks == [] - - class TagRoutingFallbackRouter: """Records fallback attempts while exposing tag-routing deployment lookups.""" @@ -723,9 +725,11 @@ def test_can_satisfy_confirmed_routing_tags_true_when_deployment_matches(): is True ) + class RecordingFailRouter: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def __init__(self): self.attempted_models = [] @@ -1238,6 +1242,7 @@ class TestRunAsyncFallbackTriggersCooldown: class RouterWithLoggingKwarg: fallback_access_check = None fallback_budget_check = None + tag_routing_prefix = None def __init__(self): self.cooldown_time = 60.0 From 22780db2d85251d5a738a8a908f79d7857823762 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sun, 11 Oct 2026 10:03:39 -0700 Subject: [PATCH 5/8] style(router): ruff-format tag_based_routing after fallback skip fix Satisfy the PR lint format gate on the changed litellm Python file. --- litellm/router_strategy/tag_based_routing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index de4c9a3335d..2d9d0fa2e38 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -760,4 +760,5 @@ def can_satisfy_confirmed_routing_tags( for d in candidates ) + _get_tags_from_request_kwargs = get_tags_from_request_kwargs From 08792d82d75506310e49351fa9696864af332d81 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sun, 11 Oct 2026 10:08:04 -0700 Subject: [PATCH 6/8] fix(router): update tag-routing test fake for get_all_deployments Router renamed _get_all_deployments to get_all_deployments; the TagRoutingFallbackRouter fake still used the private name, so can_satisfy_confirmed_routing_tags saw an empty pool and fail-opened. --- tests/unit/router_utils/test_fallback_event_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index 1cba3f8cab2..998f8308dd3 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -590,7 +590,7 @@ class TagRoutingFallbackRouter: def log_retry(self, kwargs, e): return kwargs - def _get_all_deployments(self, model_name: str, **kwargs): + def get_all_deployments(self, model_name: str, **kwargs): return list(self.deployments_by_group.get(model_name, [])) async def async_function_with_fallbacks(self, *args, **kwargs): From 720aa691c2b39267d5a7e628a9171a4cba3c7af7 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sat, 17 Oct 2026 10:07:45 -0700 Subject: [PATCH 7/8] test(router): cover can_satisfy_confirmed_routing_tags early-return branches --- .../test_fallback_event_handlers.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index f12d720f2a1..6003195b0b2 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -753,6 +753,104 @@ def test_can_satisfy_confirmed_routing_tags_true_when_deployment_matches(): ) +_VERTEX_ONLY = { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex", "default"]}, + } + ], +} +_VERTEX_REGEX = { + "chain/2-vertex": [ + { + "model_name": "chain/2-vertex", + "litellm_params": {"tags": ["inference:vertex"], "tag_regex": ["^inference:"]}, + } + ], +} + + +@pytest.mark.parametrize( + ("deployments", "router_filtering", "model", "request_kwargs", "expected"), + [ + pytest.param(_VERTEX_ONLY, True, "chain/2-vertex", None, True, id="no-request-kwargs"), + pytest.param( + _VERTEX_ONLY, True, "chain/2-vertex", {"metadata": {"tags": ["inference:bedrock"]}}, True, id="unconfirmed" + ), + pytest.param( + _VERTEX_ONLY, + True, + "chain/unknown", + {"metadata": {"tags": ["route:inference:bedrock"]}}, + True, + id="no-group", + ), + pytest.param( + _VERTEX_ONLY, + False, + "chain/2-vertex", + {"metadata": {"tags": ["route:inference:bedrock"]}}, + True, + id="filtering-off", + ), + pytest.param( + _VERTEX_ONLY, + False, + "chain/2-vertex", + {"metadata": {"tags": ["route:inference:bedrock"]}, "enable_tag_filtering": True}, + False, + id="request-enables-filtering", + ), + pytest.param( + _VERTEX_ONLY, + True, + "chain/2-vertex", + {"metadata": {"tags": ["route:&inference:bedrock"]}}, + False, + id="confirmed-required-missing", + ), + pytest.param( + _VERTEX_ONLY, + True, + "chain/2-vertex", + {"metadata": {"tags": ["route:!inference:vertex"]}}, + False, + id="confirmed-excluded-all", + ), + pytest.param( + _VERTEX_ONLY, + True, + "chain/2-vertex", + {"metadata": {"tags": ["route:&inference:vertex"]}}, + True, + id="confirmed-required-present", + ), + pytest.param( + _VERTEX_REGEX, + True, + "chain/2-vertex", + {"metadata": {"tags": ["route:inference:bedrock"]}}, + True, + id="tag-regex-deployment", + ), + ], +) +def test_can_satisfy_confirmed_routing_tags_branches(deployments, router_filtering, model, request_kwargs, expected): + from litellm.router_strategy.tag_based_routing import can_satisfy_confirmed_routing_tags + + router = TagRoutingFallbackRouter(deployments) + router.enable_tag_filtering = router_filtering + assert can_satisfy_confirmed_routing_tags(router, model, request_kwargs) is expected + + +def test_is_fallback_target_tag_satisfiable_allows_entry_without_model(): + from litellm.router_utils.fallback_event_handlers import _is_fallback_target_tag_satisfiable + + router = TagRoutingFallbackRouter(_VERTEX_ONLY) + assert _is_fallback_target_tag_satisfiable(router, {"messages": []}, {"metadata": {"tags": ["route:x"]}}) is True + + class RecordingFailRouter: fallback_access_check = None fallback_budget_check = None From a23baeb21f5ffc5d0de01e3b948123d2f0623382 Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Tue, 27 Oct 2026 10:30:58 -0700 Subject: [PATCH 8/8] style(router): keep the TID251 noqa on the cast import line --- litellm/router_strategy/tag_based_routing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/router_strategy/tag_based_routing.py b/litellm/router_strategy/tag_based_routing.py index 561abe5dfd2..4c43fd4d86c 100644 --- a/litellm/router_strategy/tag_based_routing.py +++ b/litellm/router_strategy/tag_based_routing.py @@ -12,14 +12,14 @@ import re from collections.abc import Iterable, Mapping, Sequence from dataclasses import replace from types import MappingProxyType -from typing import ( # noqa: TID251 # Router pool narrowing +from typing import ( TYPE_CHECKING, Any, Final, Literal, Protocol, TypedDict, - cast, + cast, # noqa: TID251 # Router pool narrowing overload, )