fix(router): apply team/key enable_tag_filtering to tag routing (#33436)

Team/key router_settings.enable_tag_filtering was stored and echoed by
/team/info but never applied at request time: the per-request override
whitelist in route_llm_request.py dropped it, tag filtering only read the
router-level flag, and UpdateRouterConfig silently discarded the field on
/key/generate and /config/update. Requests from teams with the toggle on
were load balanced across all deployments instead of tag-matched ones.

- add enable_tag_filtering to the router_settings_override whitelist and
  strip any client-supplied copy from the request body first, so only the
  key/team value reaches the router
- run tag filtering when the request carries enable_tag_filtering=True; a
  request-level False cannot disable a router-level True, so per-request
  settings can only scope down, never escape the global policy
- add the field to UpdateRouterConfig so key and config update paths stop
  dropping it, and to all_litellm_params so it never leaks into provider
  request bodies
- allow it through Router.update_settings/get_settings so the global UI
  toggle persists across DB config reloads

Resolves LIT-4390
This commit is contained in:
Yassin Kortam 2026-07-16 14:41:24 -07:00 • committed by GitHub
parent fb9cb67a5d
commit 21ba9692c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 195 additions and 1 deletions

View file

@ -362,6 +362,8 @@ async def route_request(
for _key in _MOCK_TESTING_KWARG_NAMES:
data.pop(_key, None)
data.pop("enable_tag_filtering", None)
team_id = get_team_id_from_data(data)
router_model_names = llm_router.model_names if llm_router is not None else []
is_proxy_admin_without_team = team_id is None and _is_proxy_admin_request(data)
@ -410,6 +412,7 @@ async def route_request(
"timeout",
"model_group_retry_policy",
"routing_strategy",
"enable_tag_filtering",
]
# Merge override settings into data (only if not already set in request)

View file

@ -9778,6 +9778,7 @@ class Router:
"retry_policy",
"model_group_alias",
"enable_weighted_failover",
"enable_tag_filtering",
]
for var in vars_to_include:
@ -9814,6 +9815,7 @@ class Router:
"model_group_retry_policy",
"model_group_alias",
"enable_weighted_failover",
"enable_tag_filtering",
]
_int_settings = [

View file

@ -160,8 +160,14 @@ async def get_deployments_for_tag(
Returns a list of deployments that match the requested model and tags in the request.
Executes tag based filtering based on the tags in request metadata and the tags on the deployments
Runs when the router-level `enable_tag_filtering` is True or the request carries
`enable_tag_filtering=True` (set from key/team router_settings by the proxy).
A request-level False never disables a router-level True, so per-request settings
cannot escape an operator's global tag-routing policy.
"""
if llm_router_instance.enable_tag_filtering is not True:
request_enable_tag_filtering = request_kwargs.get("enable_tag_filtering") if request_kwargs else None
if request_enable_tag_filtering is not True and llm_router_instance.enable_tag_filtering is not True:
return healthy_deployments
if request_kwargs is None:

View file

@ -117,6 +117,7 @@ class UpdateRouterConfig(BaseModel):
fallbacks: Optional[List[dict]] = None
context_window_fallbacks: Optional[List[dict]] = None
model_group_alias: Optional[Dict[str, Union[str, Dict]]] = {}
enable_tag_filtering: Optional[bool] = None
model_config = ConfigDict(protected_namespaces=())

View file

@ -3222,6 +3222,7 @@ all_litellm_params = (
"shared_session",
"search_tool_name",
"order",
"enable_tag_filtering",
"enable_json_schema_validation",
"use_xai_oauth",
"_litellm_rate_limit_descriptors",

View file

@ -124,3 +124,18 @@ def test_proxy_exception_str_returns_message():
"param": "key",
"code": "401",
}
def test_key_request_router_settings_keeps_enable_tag_filtering():
"""``router_settings`` on key requests validates through
``UpdateRouterConfig``; a field missing from that model is silently
dropped at parse time, so a key's "Enable Tag Filtering" toggle would
never reach the DB even though the team path (plain dict) kept it."""
from litellm.proxy._types import GenerateKeyRequest
req = GenerateKeyRequest(router_settings={"enable_tag_filtering": True, "num_retries": 2})
assert req.router_settings is not None
dumped = req.router_settings.model_dump(exclude_none=True)
assert dumped["enable_tag_filtering"] is True
assert dumped["num_retries"] == 2

View file

@ -819,3 +819,71 @@ async def test_route_request_realtime_transcription_session_resolves_credentials
)
assert mock_handler.call_args.kwargs["api_key"] == "transcription-key"
@pytest.mark.asyncio
async def test_route_request_merges_enable_tag_filtering_from_override():
"""Key/team router_settings carry enable_tag_filtering; the override
whitelist must forward it to the router call or the team's tag-routing
toggle saved in the UI is silently ignored at request time."""
data = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello"}],
"router_settings_override": {
"enable_tag_filtering": True,
},
}
llm_router = MagicMock()
llm_router.acompletion.return_value = "success"
response = await route_request(data, llm_router, None, "acompletion")
assert response == "success"
call_kwargs = llm_router.acompletion.call_args[1]
assert call_kwargs["enable_tag_filtering"] is True
@pytest.mark.asyncio
async def test_route_request_strips_client_supplied_enable_tag_filtering():
"""enable_tag_filtering influences deployment selection and is only
trusted when it comes from key/team router_settings via
router_settings_override. A caller putting it in the request body must
not reach the router with it."""
data = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello"}],
"enable_tag_filtering": True,
}
llm_router = MagicMock()
llm_router.acompletion.return_value = "ok"
await route_request(data, llm_router, None, "acompletion")
call_kwargs = llm_router.acompletion.call_args[1]
assert "enable_tag_filtering" not in call_kwargs
assert "enable_tag_filtering" not in data
@pytest.mark.asyncio
async def test_route_request_override_enable_tag_filtering_beats_body_value():
"""A client-sent enable_tag_filtering must not shadow the key/team
setting: the body copy is stripped first, so the override value is the
one the router sees."""
data = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello"}],
"enable_tag_filtering": False,
"router_settings_override": {
"enable_tag_filtering": True,
},
}
llm_router = MagicMock()
llm_router.acompletion.return_value = "ok"
await route_request(data, llm_router, None, "acompletion")
call_kwargs = llm_router.acompletion.call_args[1]
assert call_kwargs["enable_tag_filtering"] is True

View file

@ -1019,3 +1019,99 @@ async def test_negation_removes_tag_regex_deployment_falls_to_ban_only():
mock_response="hi",
)
assert response._hidden_params["model_id"] == "openai-deployment"
@pytest.mark.asyncio()
async def test_request_level_enable_tag_filtering_applies_when_global_off():
"""
A request carrying enable_tag_filtering=True (set by the proxy from key/team
router_settings) must activate tag filtering even when the router-level flag
is off. Without this, a team's "Enable Tag Filtering" toggle saved in the UI
is silently ignored at request time.
"""
router = litellm.Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4o",
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
"tags": ["teamA"],
},
"model_info": {"id": "team-a-deployment"},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4o-mini",
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
"tags": ["teamB"],
},
"model_info": {"id": "team-b-deployment"},
},
],
enable_tag_filtering=False,
)
for _ in range(5):
response = await router.acompletion(
model="gpt-4",
messages=[{"role": "user", "content": "hi"}],
metadata={"tags": ["teamA"]},
enable_tag_filtering=True,
mock_response="hi",
)
assert response._hidden_params["model_id"] == "team-a-deployment"
for _ in range(5):
response = await router.acompletion(
model="gpt-4",
messages=[{"role": "user", "content": "hi"}],
metadata={"tags": ["teamB"]},
enable_tag_filtering=True,
mock_response="hi",
)
assert response._hidden_params["model_id"] == "team-b-deployment"
@pytest.mark.asyncio()
async def test_request_level_enable_tag_filtering_false_cannot_disable_global():
"""
A request-level enable_tag_filtering=False must not bypass a router-level
True: tag filtering can be an operator-level restriction on which
deployments a caller may reach, so per-request settings may only scope
down, never escape the global policy.
"""
router = litellm.Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4o",
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
"tags": ["teamA"],
},
"model_info": {"id": "team-a-deployment"},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4o-mini",
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
"tags": ["teamB"],
},
"model_info": {"id": "team-b-deployment"},
},
],
enable_tag_filtering=True,
)
for _ in range(5):
response = await router.acompletion(
model="gpt-4",
messages=[{"role": "user", "content": "hi"}],
metadata={"tags": ["teamA"]},
enable_tag_filtering=False,
mock_response="hi",
)
assert response._hidden_params["model_id"] == "team-a-deployment"

View file

@ -32245,6 +32245,8 @@ export interface components {
}[] | null;
/** Cooldown Time */
cooldown_time?: number | null;
/** Enable Tag Filtering */
enable_tag_filtering?: boolean | null;
/** Fallbacks */
fallbacks?: {
[key: string]: unknown;