From 6d7c1c6a558f57264c6ca36710dc9afc38fccb81 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 21 Oct 2026 15:39:46 +0300 Subject: [PATCH] fix(router): constrain same-name deployment routing by key access group When a key is restricted to a subset of its team's model access groups, deployment selection for same-named deployments used the union of key and team access groups, so requests leaked into deployments of a group the key does not have. Honor the key's own access groups when set (unless it carries the all-team-models sentinel), matching key-vs-team model resolution elsewhere in the codebase. Fixes #47825 --- litellm/router.py | 6 +- tests/unit/test_router/test_router.py | 118 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index da04e59f18a..20210f15eac 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -14723,7 +14723,11 @@ class Router: object_models: Final = set(getattr(user_api_key_auth, "models", []) or []) object_team_models: Final = set(getattr(user_api_key_auth, "team_models", []) or []) - allowed_models: Final = object_models | object_team_models + allowed_models: Final = ( + object_models + if object_models and "all-team-models" not in object_models + else object_models | object_team_models + ) if not allowed_models: return healthy_deployments diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 9cc1bf6ce79..af930c05518 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -10833,6 +10833,124 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): assert ["AG2"] in deployment_groups +def test_key_access_group_narrows_below_team_for_same_public_model(): + """ + When the team grants two access groups serving the same public model but the + key is restricted to one of them, deployment selection must be constrained to + the key's group. The team's broader access must not widen the key's choice of + same-named deployments. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["zz-grp-A"]}, + }, + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["zz-grp-B"]}, + }, + ] + ) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team1", + models=["zz-grp-A"], + team_models=["zz-grp-A", "zz-grp-B"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="zz-test-same", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team1", + "user_api_key_auth": scoped_key, + } + }, + ) + + assert len(deployments) == 1 + assert deployments[0].get("model_info", {}).get("access_groups") == ["zz-grp-A"] + + seen = set() + for _ in range(20): + response = router.completion( + model="zz-test-same", + messages=[{"role": "user", "content": "hello"}], + metadata={"user_api_key_team_id": "team1", "user_api_key_auth": scoped_key}, + ) + seen.add(response.choices[0].message.content) + + assert seen == {"response-via-AG1"} + + +def test_key_all_team_models_inherits_team_access_groups(): + """ + A key whose models field is the ``all-team-models`` sentinel inherits the + team's access groups, so both same-named deployments stay selectable. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["zz-grp-A"]}, + }, + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["zz-grp-B"]}, + }, + ] + ) + + inherit_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team1", + models=["all-team-models"], + team_models=["zz-grp-A", "zz-grp-B"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="zz-test-same", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team1", + "user_api_key_auth": inherit_key, + } + }, + ) + + deployment_groups = [ + d.get("model_info", {}).get("access_groups") for d in deployments + ] + assert ["zz-grp-A"] in deployment_groups + assert ["zz-grp-B"] in deployment_groups + + def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( monkeypatch: pytest.MonkeyPatch, ):