fix(router): keep deployment tags out of retry and fallback tag routing (#40226)

* fix(router): keep deployment tags out of retry and fallback tag routing

Deployment-level tags merged into request metadata for spend attribution were also read as caller tag constraints on later attempts, so a tag-filtered group re-narrowed to the deployment that just failed. Snapshot the caller's routing tags before the merge and have tag routing read that snapshot.

Resolves LIT-7113

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

* refactor(router): drop redundant comment in tag routing

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

* test(router): make tag retry regression deterministic and cover routing snapshot helper

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

---------

Co-authored-by: yassin <yassin@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-08 21:15:36 +00:00 committed by GitHub
parent 9fffda4117
commit 35451ecc7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 78 additions and 5 deletions

View file

@ -1464,6 +1464,7 @@ SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY: Final = "_session_deployment_affin
OUTPUT_TOKEN_CEILING_PARAMS: Final = frozenset({"max_tokens", "max_completion_tokens", "max_output_tokens"})
CLIENT_OUTPUT_CEILING_METADATA_KEY: Final = "_client_output_ceiling"
CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
ROUTING_REQUEST_TAGS_METADATA_KEY: Final = "_routing_request_tags"
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted"

View file

@ -13,6 +13,7 @@ from litellm.constants import (
CLIENT_OUTPUT_CEILING_METADATA_KEY,
CONSUMED_REQUEST_TAGS_METADATA_KEY,
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
ROUTING_REQUEST_TAGS_METADATA_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
from litellm.integrations.custom_logger import CustomLogger
@ -509,6 +510,7 @@ LITELLM_PROXY_INTERNAL_METADATA_KEYS: Final = frozenset(
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
CONSUMED_REQUEST_TAGS_METADATA_KEY,
CLIENT_OUTPUT_CEILING_METADATA_KEY,
ROUTING_REQUEST_TAGS_METADATA_KEY,
"disable_global_guardrails",
"disable_global_guardrail",
"opted_out_global_guardrails",

View file

@ -24,6 +24,7 @@ from litellm.constants import (
LITELLM_PROXY_MASTER_KEY_ALIAS,
OTEL_SERVICE_NAME_METADATA_KEYS,
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
ROUTING_REQUEST_TAGS_METADATA_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
SESSION_ID_GENERATED_METADATA_KEY,
SESSION_ID_OMITTED_METADATA_KEY,
@ -290,6 +291,7 @@ _UNTRUSTED_METADATA_CONTROL_FIELDS: Final = (
GATEWAY_INJECTED_CACHE_METADATA_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
CONSUMED_REQUEST_TAGS_METADATA_KEY,
ROUTING_REQUEST_TAGS_METADATA_KEY,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
"standard_logging_object",
"proxy_server_request",

View file

@ -62,6 +62,7 @@ from litellm.constants import (
DEFAULT_MAX_LRU_CACHE_SIZE,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
OUTPUT_TOKEN_CEILING_PARAMS,
ROUTING_REQUEST_TAGS_METADATA_KEY,
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
@ -3772,6 +3773,11 @@ class Router:
refund_stale_reservation_before_retry(self.cache, kwargs)
set_io_token_rate_limit_request_kwargs(kwargs, store_in_context=deployment_has_io_token_limits(deployment))
kwargs[metadata_variable_name].setdefault(
ROUTING_REQUEST_TAGS_METADATA_KEY,
tuple(_get_tags_from_request_kwargs(kwargs, metadata_variable_name=metadata_variable_name)),
)
## DEPLOYMENT-LEVEL TAGS
deployment_tags: Final = deployment.get("litellm_params", {}).get("tags")
if deployment_tags:

View file

@ -13,7 +13,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, overload
from litellm._logging import verbose_logger
from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY
from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY, ROUTING_REQUEST_TAGS_METADATA_KEY
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
from litellm.types.router import ConsumedRequestTagsStamp, DeploymentTypedDict, RouterErrors
@ -461,7 +461,10 @@ def _request_tags_after_router_consumption(metadata: object, model: str) -> Sequ
if not isinstance(metadata, Mapping):
return None
typed_metadata: Final[Mapping[str, object]] = metadata
request_tags: Final = _tags_in_metadata(typed_metadata)
request_tags: Final = _tags_in_metadata(
typed_metadata,
key=ROUTING_REQUEST_TAGS_METADATA_KEY if ROUTING_REQUEST_TAGS_METADATA_KEY in typed_metadata else "tags",
)
stamp: Final = typed_metadata.get(CONSUMED_REQUEST_TAGS_METADATA_KEY)
if not isinstance(stamp, ConsumedRequestTagsStamp) or stamp.model_group != model:
return request_tags
@ -646,7 +649,7 @@ async def get_deployments_for_tag(
return healthy_deployments
def _tags_in_metadata(metadata: object) -> list[str]:
def _tags_in_metadata(metadata: object, key: str = "tags") -> list[str]:
"""
Tags out of a metadata bucket the caller controls the shape of.
@ -657,7 +660,7 @@ def _tags_in_metadata(metadata: object) -> list[str]:
if not isinstance(metadata, Mapping):
return []
typed_metadata: Final[Mapping[str, object]] = metadata
tags: Final = typed_metadata.get("tags")
tags: Final = typed_metadata.get(key)
if isinstance(tags, str) or not isinstance(tags, Sequence):
return []
typed_tags: Final[Sequence[object]] = tags

View file

@ -3031,6 +3031,21 @@ def test_request_tags_after_router_consumption_drops_only_the_consumed_tags():
assert _request_tags_after_router_consumption(partially_consumed, "gemini-flash") == ("deploy:us",)
def test_request_tags_after_router_consumption_ignores_tags_merged_from_prior_deployments():
from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY, ROUTING_REQUEST_TAGS_METADATA_KEY
from litellm.router_strategy.tag_based_routing import _request_tags_after_router_consumption
from litellm.types.router import ConsumedRequestTagsStamp
metadata = {
"tags": ["route", "&region:eu", "free"],
ROUTING_REQUEST_TAGS_METADATA_KEY: ("route", "&region:eu"),
"inherited_tags": ["&region:eu"],
CONSUMED_REQUEST_TAGS_METADATA_KEY: ConsumedRequestTagsStamp(model_group="gemini-flash", tags=("route",)),
}
assert _request_tags_after_router_consumption(metadata, "gemini-flash") == ("&region:eu",)
assert _request_tags_after_router_consumption(metadata, "other-group") == ["route", "&region:eu"]
@pytest.mark.asyncio()
async def test_non_router_tags_still_pick_the_matching_tier_deployment():
# tags=["route", "deploy:us"]: "route" picks the router and is spent there,

View file

@ -40,7 +40,7 @@ from litellm.router import (
_is_retriable_anthropic_status,
)
from litellm.router_strategy import simple_shuffle
from litellm.types.router import DeploymentTypedDict
from litellm.types.router import DeploymentTypedDict, RetryPolicy
def test_update_kwargs_does_not_mutate_defaults_and_merges_metadata():
@ -6132,6 +6132,50 @@ def test_update_kwargs_with_deployment_no_tags():
assert "tags" not in kwargs["metadata"]
@pytest.mark.asyncio
async def test_retry_does_not_narrow_tag_filtered_group_to_failed_deployments_tags():
router = Router(
model_list=[
{
"model_name": "tagged-group",
"litellm_params": {
"model": "openai/gpt-5.5",
"api_key": "fake-key",
"tags": ["free"],
"input_cost_per_token": 0.000001,
"output_cost_per_token": 0.000001,
"mock_response": "litellm.ContextWindowExceededError",
},
"model_info": {"id": "tagged-failing"},
},
{
"model_name": "tagged-group",
"litellm_params": {
"model": "openai/gpt-5.5",
"api_key": "fake-key",
"input_cost_per_token": 0.001,
"output_cost_per_token": 0.001,
"mock_response": "ok",
},
"model_info": {"id": "untagged-healthy"},
},
],
routing_strategy="cost-based-routing",
enable_tag_filtering=True,
num_retries=2,
retry_after=0,
retry_policy=RetryPolicy(BadRequestErrorRetries=2),
)
metadata: Final[dict[str, object]] = {}
response = await router.acompletion(
model="tagged-group", messages=[{"role": "user", "content": "hi"}], metadata=metadata
)
assert response._hidden_params["model_id"] == "untagged-healthy"
assert metadata["tags"] == ["free"]
def test_update_kwargs_with_deployment_merges_tools():
"""
Test that when both deployment litellm_params and request have tools,