mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
fb9cb67a5d
commit
21ba9692c3
9 changed files with 195 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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=())
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue