From 9a8241822aa8adc617efe9999de086f5536a883e Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Thu, 14 May 2026 12:22:46 +0200 Subject: [PATCH 1/8] feat(proxy): add blocked flag to models for pause/resume from the UI Closes #13703. Addresses requests in #361 from @ingvarson, @DmitriyAlergant, @addy999. Adds a persistent, admin-toggleable "blocked" boolean to LiteLLM_ProxyModelTable so operators can pause an individual model from the dashboard without removing it from the proxy config. Backend changes (UI follow-up will land separately): - Schema: new `blocked Boolean @default(false)` column on LiteLLM_ProxyModelTable, mirroring the existing precedent on LiteLLM_TeamTable and LiteLLM_VerificationToken. Synced across all three schema.prisma copies and a migration in litellm-proxy-extras. - API: `updateDeployment.blocked: Optional[bool]` is accepted by the existing PATCH /model/{model_id}/update endpoint with proper partial-update semantics (None means "leave unchanged"). - Router: `_get_healthy_deployments` and its async sibling skip any deployment whose model_info carries `blocked=True`, in the same loop that already skips cooldown-tripped deployments. Paused deployments fall back to siblings of the same model_name exactly the way cooldown failover already works. - /v1/models: a new helper `Router.get_fully_blocked_model_names()` hides a model from the public listing only when every backing deployment is blocked, so partial pauses stay invisible to clients but admins can still see them via /model/info. Tests (in tests/test_litellm/, all mocked, no real API calls): - test_router.py: 5 named tests covering the new helper and both routing-filter paths. - test_model_management_endpoints.py: 5 named tests covering each input class of `update_db_model.blocked` and `ProxyConfig.get_model_info_with_id` propagation. make lint and the targeted test files both pass locally. --- .../migration.sql | 2 + .../litellm_proxy_extras/schema.prisma | 5 +- litellm/proxy/_types.py | 1 + .../model_management_endpoints.py | 3 + litellm/proxy/proxy_server.py | 13 ++++ litellm/proxy/schema.prisma | 5 +- litellm/router.py | 36 ++++++++- litellm/types/router.py | 1 + schema.prisma | 5 +- .../test_model_management_endpoints.py | 74 +++++++++++++++++++ tests/test_litellm/test_router.py | 63 ++++++++++++++++ 11 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql new file mode 100644 index 000000000000..ce8fccdbddf5 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_ProxyModelTable" ADD COLUMN IF NOT EXISTS "blocked" BOOLEAN NOT NULL DEFAULT false; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index b53507abe6a6..78143fe0411c 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -48,9 +48,10 @@ model LiteLLM_CredentialsTable { // Models on proxy model LiteLLM_ProxyModelTable { model_id String @id @default(uuid()) - model_name String + model_name String litellm_params Json - model_info Json? + model_info Json? + blocked Boolean @default(false) created_at DateTime @default(now()) @map("created_at") created_by String updated_at DateTime @default(now()) @updatedAt @map("updated_at") diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d4fa497698a6..c989f5dff130 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4549,6 +4549,7 @@ class PrismaCompatibleUpdateDBModel(TypedDict, total=False): model_name: str litellm_params: str model_info: str + blocked: bool updated_at: str updated_by: str diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index af84bc123fff..f8c28c684cb0 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -150,6 +150,9 @@ def update_db_model( model_info[key] = value.isoformat() prisma_compatible_model_dict["model_info"] = json.dumps(model_info) + if updated_patch.blocked is not None: + prisma_compatible_model_dict["blocked"] = updated_patch.blocked + return prisma_compatible_model_dict diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 46ebff515b7a..eeda5e83145d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4728,6 +4728,7 @@ def get_model_info_with_id(self, model, db_model=False) -> RouterModelInfo: if _id is not None: model.model_info["id"] = _id model.model_info["db_model"] = True + model.model_info["blocked"] = bool(getattr(model, "blocked", False)) if premium_user is True: # seeing "created_at", "updated_at", "created_by", "updated_by" is a LiteLLM Enterprise Feature @@ -8099,6 +8100,12 @@ async def model_list( only_model_access_groups=only_model_access_groups or False, ) + # Hide paused models from the public listing (admins manage them via /model/info) + if llm_router is not None: + blocked_names = llm_router.get_fully_blocked_model_names() + if blocked_names: + all_models = [m for m in all_models if m not in blocked_names] + # Build response data with all proxy models model_data = [] for model in all_models: @@ -8132,6 +8139,12 @@ async def model_list( user_api_key_cache=user_api_key_cache, ) + # Hide paused models from the public listing (admins manage them via /model/info) + if llm_router is not None: + blocked_names = llm_router.get_fully_blocked_model_names() + if blocked_names: + all_models = [m for m in all_models if m not in blocked_names] + # Build response data model_data = [] for model in all_models: diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index b53507abe6a6..78143fe0411c 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -48,9 +48,10 @@ model LiteLLM_CredentialsTable { // Models on proxy model LiteLLM_ProxyModelTable { model_id String @id @default(uuid()) - model_name String + model_name String litellm_params Json - model_info Json? + model_info Json? + blocked Boolean @default(false) created_at DateTime @default(now()) @map("created_at") created_by String updated_at DateTime @default(now()) @updatedAt @map("updated_at") diff --git a/litellm/router.py b/litellm/router.py index 3340e06627e0..a0f7fafffd9f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -30,6 +30,7 @@ List, Literal, Optional, + Set, Tuple, Union, cast, @@ -6838,8 +6839,9 @@ def _get_healthy_deployments(self, model: str, parent_otel_span: Optional[Span]) for deployment in _all_deployments: if deployment["model_info"]["id"] in unhealthy_deployments: continue - else: - healthy_deployments.append(deployment) + if deployment.get("model_info", {}).get("blocked") is True: + continue + healthy_deployments.append(deployment) return healthy_deployments, _all_deployments @@ -6869,8 +6871,11 @@ async def _async_get_healthy_deployments( unhealthy_deployments_set = set(unhealthy_deployments) healthy_deployments: list = [] for deployment in _all_deployments: - if deployment["model_info"]["id"] not in unhealthy_deployments_set: - healthy_deployments.append(deployment) + if deployment["model_info"]["id"] in unhealthy_deployments_set: + continue + if deployment.get("model_info", {}).get("blocked") is True: + continue + healthy_deployments.append(deployment) return healthy_deployments, _all_deployments def routing_strategy_pre_call_checks(self, deployment: dict): @@ -9120,6 +9125,29 @@ def get_model_names(self, team_id: Optional[str] = None) -> List[str]: return model_names + def get_fully_blocked_model_names(self) -> Set[str]: + """ + Returns the set of model_names where every backing deployment has `blocked=True`. + + Used by `/v1/models` to hide paused models from client listings while still + surfacing them on admin endpoints (e.g. `/model/info`). A model with at least + one non-blocked deployment is still serviceable and remains visible. + """ + deployments = self.get_model_list() or [] + blocked_by_name: Dict[str, bool] = {} + for deployment in deployments: + name = deployment.get("model_name") or "" + if not name: + continue + is_blocked = (deployment.get("model_info") or {}).get("blocked") is True + if name in blocked_by_name: + blocked_by_name[name] = blocked_by_name[name] and is_blocked + else: + blocked_by_name[name] = is_blocked + return { + name for name, fully_blocked in blocked_by_name.items() if fully_blocked + } + def _get_team_specific_model( self, deployment: DeploymentTypedDict, team_id: Optional[str] = None ) -> Optional[str]: diff --git a/litellm/types/router.py b/litellm/types/router.py index 926815ba317f..72866f6f5e3d 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -323,6 +323,7 @@ class updateDeployment(BaseModel): model_name: Optional[str] = None litellm_params: Optional[updateLiteLLMParams] = None model_info: Optional[ModelInfo] = None + blocked: Optional[bool] = None model_config = ConfigDict(protected_namespaces=()) diff --git a/schema.prisma b/schema.prisma index b53507abe6a6..78143fe0411c 100644 --- a/schema.prisma +++ b/schema.prisma @@ -48,9 +48,10 @@ model LiteLLM_CredentialsTable { // Models on proxy model LiteLLM_ProxyModelTable { model_id String @id @default(uuid()) - model_name String + model_name String litellm_params Json - model_info Json? + model_info Json? + blocked Boolean @default(false) created_at DateTime @default(now()) @map("created_at") created_by String updated_at DateTime @default(now()) @updatedAt @map("updated_at") diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index f0bf4578636d..7068da8b21dd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -1446,3 +1446,77 @@ async def test_multiple_deployments_mixed_filtering(self): result = await _get_team_deployments(team_id, prisma_client) assert len(result) == 1 assert result[0] is dep1 + + +def _build_db_model_for_blocked_test(): + from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo + + return Deployment( + model_name="gpt-4o", + litellm_params=LiteLLM_Params(model="openai/gpt-4o"), + model_info=ModelInfo(id="dep-0"), + ) + + +class TestUpdateDBModelBlocked: + """`update_db_model` must thread `blocked` through to the Prisma payload only + when the caller explicitly set it — PATCH semantics: an absent field means + "leave the stored value untouched".""" + + def test_update_db_model_passes_blocked_true_to_db(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + + result = update_db_model( + db_model=_build_db_model_for_blocked_test(), + updated_patch=updateDeployment(blocked=True), + ) + assert result["blocked"] is True + + def test_update_db_model_passes_blocked_false_to_db(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + + result = update_db_model( + db_model=_build_db_model_for_blocked_test(), + updated_patch=updateDeployment(blocked=False), + ) + assert result["blocked"] is False + + def test_update_db_model_omits_blocked_when_patch_is_none(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + + result = update_db_model( + db_model=_build_db_model_for_blocked_test(), + updated_patch=updateDeployment(), + ) + assert "blocked" not in result + + +class TestGetModelInfoWithIdBlocked: + """`ProxyConfig.get_model_info_with_id` must propagate the DB-level `blocked` + column into the in-memory `model_info` dict so the router filter can read it.""" + + def test_get_model_info_with_id_propagates_blocked_true(self): + from litellm.proxy.proxy_server import ProxyConfig + + model = MagicMock() + model.model_id = "dep-1" + model.model_info = {} + model.blocked = True + info = ProxyConfig().get_model_info_with_id(model=model, db_model=True) + assert info.id == "dep-1" + assert getattr(info, "blocked") is True + + def test_get_model_info_with_id_defaults_blocked_to_false_when_missing(self): + from litellm.proxy.proxy_server import ProxyConfig + + model = MagicMock(spec=["model_id", "model_info"]) + model.model_id = "dep-2" + model.model_info = {} + info = ProxyConfig().get_model_info_with_id(model=model, db_model=True) + assert getattr(info, "blocked") is False diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 48facace5289..d7672caff67d 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3697,3 +3697,66 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): default_router.default_deployment["litellm_params"]["model"] == "openai/will-be-overridden" ) + + +def _router_with_two_deployments(blocked_flags): + import litellm + + model_list = [] + for idx, blocked in enumerate(blocked_flags): + model_list.append( + { + "model_name": "gpt-4o", + "litellm_params": {"model": f"openai/gpt-4o-{idx}"}, + "model_info": {"id": f"dep-{idx}", "blocked": blocked}, + } + ) + return litellm.Router(model_list=model_list) + + +def test_get_fully_blocked_model_names_marks_name_when_all_deployments_blocked(): + router = _router_with_two_deployments([True, True]) + assert router.get_fully_blocked_model_names() == {"gpt-4o"} + + +def test_get_fully_blocked_model_names_keeps_name_when_partial_blocked(): + router = _router_with_two_deployments([True, False]) + assert router.get_fully_blocked_model_names() == set() + + +def test_get_fully_blocked_model_names_treats_missing_key_as_unblocked(): + import litellm + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "dep-0"}, + } + ] + ) + assert router.get_fully_blocked_model_names() == set() + + +@pytest.mark.asyncio +async def test_async_get_healthy_deployments_skips_blocked_deployment(): + router = _router_with_two_deployments([True, False]) + healthy, all_dep = await router._async_get_healthy_deployments( + model="gpt-4o", parent_otel_span=None + ) + healthy_ids = [d["model_info"]["id"] for d in healthy] + assert "dep-0" not in healthy_ids + assert "dep-1" in healthy_ids + assert len(all_dep) == 2 + + +def test_get_healthy_deployments_sync_skips_blocked_deployment(): + router = _router_with_two_deployments([False, True]) + healthy, all_dep = router._get_healthy_deployments( + model="gpt-4o", parent_otel_span=None + ) + healthy_ids = [d["model_info"]["id"] for d in healthy] + assert "dep-0" in healthy_ids + assert "dep-1" not in healthy_ids + assert len(all_dep) == 2 From 827b57f2a5d5a32c69de9cb1ab66efed24f96f0a Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Thu, 14 May 2026 13:06:49 +0200 Subject: [PATCH 2/8] fix(router): also skip blocked deployments on the primary routing path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile P1 and Veria-AI's matching finding on PR #27927: the previous commit added the `blocked` filter to `_get_healthy_deployments` and its async sibling, but those helpers serve the retry / health-check polling loops, not the first-request routing path. Initial selection flows through `async_get_healthy_deployments` (async) and `get_available_deployment` (sync), and neither dropped blocked deployments — so a paused model remained reachable on the first try, defeating the feature's intent. - Extract `Router._filter_blocked_deployments` next to `_filter_cooldown_deployments` and call it on both public routing paths, in the same position the cooldown filter is applied. - Handle the `specific_deployment` / dict early-return case: when a caller targets a single deployment that is blocked, raise `RouterRateLimitErrorBasic` so the request never lands on the paused model. - Re-use the helper from `_get_healthy_deployments` / `_async_get_healthy_deployments` so the retry loops match the primary path exactly (no drift between filters). Also addresses Greptile P2: declare `blocked: Optional[bool] = None` on `ModelInfo` so the field is visible to type checkers and IDEs instead of relying on `model_config = ConfigDict(extra="allow")`. Adds 4 small named tests covering the new helper and both public routing paths, including the addressed-by-id blocked-dict case. All existing blocked tests still pass. --- litellm/router.py | 48 ++++++++++++++++++++++--------- litellm/types/router.py | 3 ++ tests/test_litellm/test_router.py | 33 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index a0f7fafffd9f..e2d88daed629 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -6835,13 +6835,11 @@ def _get_healthy_deployments(self, model: str, parent_otel_span: Optional[Span]) unhealthy_deployments = _get_cooldown_deployments( litellm_router_instance=self, parent_otel_span=parent_otel_span ) - healthy_deployments: list = [] - for deployment in _all_deployments: - if deployment["model_info"]["id"] in unhealthy_deployments: - continue - if deployment.get("model_info", {}).get("blocked") is True: - continue - healthy_deployments.append(deployment) + unhealthy_set = set(unhealthy_deployments) + healthy_deployments: list = [ + d for d in _all_deployments if d["model_info"]["id"] not in unhealthy_set + ] + healthy_deployments = self._filter_blocked_deployments(healthy_deployments) return healthy_deployments, _all_deployments @@ -6869,13 +6867,12 @@ async def _async_get_healthy_deployments( ) # Convert to set for O(1) lookup instead of O(n) unhealthy_deployments_set = set(unhealthy_deployments) - healthy_deployments: list = [] - for deployment in _all_deployments: - if deployment["model_info"]["id"] in unhealthy_deployments_set: - continue - if deployment.get("model_info", {}).get("blocked") is True: - continue - healthy_deployments.append(deployment) + healthy_deployments: list = [ + d + for d in _all_deployments + if d["model_info"]["id"] not in unhealthy_deployments_set + ] + healthy_deployments = self._filter_blocked_deployments(healthy_deployments) return healthy_deployments, _all_deployments def routing_strategy_pre_call_checks(self, deployment: dict): @@ -10034,6 +10031,8 @@ async def async_get_healthy_deployments( ) if isinstance(healthy_deployments, dict): + if (healthy_deployments.get("model_info") or {}).get("blocked") is True: + raise RouterRateLimitErrorBasic(model=model) return healthy_deployments # Health-check-based filtering (before cooldown) @@ -10067,6 +10066,8 @@ async def async_get_healthy_deployments( ) healthy_deployments = _pre_cooldown_deployments + healthy_deployments = self._filter_blocked_deployments(healthy_deployments) + healthy_deployments = await self.async_callback_filter_deployments( model=model, healthy_deployments=healthy_deployments, @@ -10447,6 +10448,8 @@ def get_available_deployment( ) if isinstance(healthy_deployments, dict): + if (healthy_deployments.get("model_info") or {}).get("blocked") is True: + raise RouterRateLimitErrorBasic(model=model) return healthy_deployments parent_otel_span: Optional[Span] = _get_parent_otel_span_from_kwargs( @@ -10477,6 +10480,8 @@ def get_available_deployment( ) healthy_deployments = _pre_cooldown_deployments + healthy_deployments = self._filter_blocked_deployments(healthy_deployments) + # filter pre-call checks if self.enable_pre_call_checks and messages is not None: healthy_deployments = self._pre_call_checks( @@ -10713,6 +10718,21 @@ def _filter_cooldown_deployments( if deployment["model_info"]["id"] not in cooldown_set ] + def _filter_blocked_deployments( + self, healthy_deployments: List[Dict] + ) -> List[Dict]: + """ + Filters out deployments that an admin has paused via `LiteLLM_ProxyModelTable.blocked`. + + Applied alongside the cooldown filter on both the primary routing path and the + retry / health-check helpers so paused deployments never serve a request. + """ + return [ + deployment + for deployment in healthy_deployments + if (deployment.get("model_info") or {}).get("blocked") is not True + ] + async def _async_filter_health_check_unhealthy_deployments( self, healthy_deployments: List[Dict], diff --git a/litellm/types/router.py b/litellm/types/router.py index 72866f6f5e3d..6601f552b524 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -133,6 +133,9 @@ class ModelInfo(BaseModel): # the model_name that can be used by the team when making LLM calls team_public_model_name: Optional[str] = None + # admin-toggled pause flag; mirrors LiteLLM_ProxyModelTable.blocked + blocked: Optional[bool] = None + def __init__(self, id: Optional[Union[str, int]] = None, **params): if id is None: id = str(uuid.uuid4()) # Generate a UUID if id is None or not provided diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d7672caff67d..2d23dfd17a4f 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3760,3 +3760,36 @@ def test_get_healthy_deployments_sync_skips_blocked_deployment(): assert "dep-0" in healthy_ids assert "dep-1" not in healthy_ids assert len(all_dep) == 2 + + +def test_filter_blocked_deployments_drops_blocked_keeps_unblocked(): + router = _router_with_two_deployments([True, False]) + filtered = router._filter_blocked_deployments(router.get_model_list() or []) + ids = [d["model_info"]["id"] for d in filtered] + assert ids == ["dep-1"] + + +@pytest.mark.asyncio +async def test_public_async_get_healthy_deployments_skips_blocked_on_primary_path(): + router = _router_with_two_deployments([True, False]) + deployments = await router.async_get_healthy_deployments( + model="gpt-4o", request_kwargs={} + ) + assert isinstance(deployments, list) + ids = [d["model_info"]["id"] for d in deployments] + assert "dep-0" not in ids + assert "dep-1" in ids + + +def test_public_get_available_deployment_skips_blocked_on_primary_path(): + router = _router_with_two_deployments([True, False]) + deployment = router.get_available_deployment(model="gpt-4o", request_kwargs={}) + assert deployment["model_info"]["id"] == "dep-1" + + +def test_get_available_deployment_raises_when_addressed_dict_is_blocked(): + from litellm.types.router import RouterRateLimitErrorBasic + + router = _router_with_two_deployments([True, True]) + with pytest.raises(RouterRateLimitErrorBasic): + router.get_available_deployment(model="dep-0", request_kwargs={}) From 70d8b9a4b5ea263c70bbb7db73339c3511283a6a Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Thu, 14 May 2026 13:28:23 +0200 Subject: [PATCH 3/8] fix(router): also skip blocked deployments on the pass-through path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Veria-AI flagged a follow-up: `get_available_deployment_for_pass_through` calls `_common_checks_available_deployment` directly rather than delegating to `get_available_deployment`, so the blocked filter the last commit added to the primary path never ran for pass-through requests. A user calling a paused deployment with `use_in_pass_through=True` could still reach it. - Apply `_filter_blocked_deployments` after the cooldown filter in the sync pass-through path, mirroring the primary `get_available_deployment` layout. - Raise `RouterRateLimitErrorBasic` when the dict early-return resolves to a blocked addressed deployment, before the existing `use_in_pass_through=False` check. - The async sibling `async_get_available_deployment_for_pass_through` delegates to `async_get_healthy_deployments` and so already inherits the filter — no change needed there. Adds 2 tests covering the sync pass-through filter and the addressed-by-id blocked-dict case. --- litellm/router.py | 5 +++++ tests/test_litellm/test_router.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index e2d88daed629..6803ba5db2e8 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10590,6 +10590,8 @@ def get_available_deployment_for_pass_through( # 2. If the returned is a specific deployment (Dict), verify and return directly if isinstance(healthy_deployments, dict): + if (healthy_deployments.get("model_info") or {}).get("blocked") is True: + raise RouterRateLimitErrorBasic(model=model) litellm_params = healthy_deployments.get("litellm_params", {}) if litellm_params.get("use_in_pass_through"): return healthy_deployments @@ -10629,6 +10631,9 @@ def get_available_deployment_for_pass_through( healthy_deployments=pass_through_deployments, cooldown_deployments=cooldown_deployments, ) + pass_through_deployments = self._filter_blocked_deployments( + pass_through_deployments + ) # 5. Apply pre-call checks (if enabled) if self.enable_pre_call_checks and messages is not None: diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 2d23dfd17a4f..a1d2c4070ca0 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3793,3 +3793,40 @@ def test_get_available_deployment_raises_when_addressed_dict_is_blocked(): router = _router_with_two_deployments([True, True]) with pytest.raises(RouterRateLimitErrorBasic): router.get_available_deployment(model="dep-0", request_kwargs={}) + + +def _router_with_two_pass_through_deployments(blocked_flags): + import litellm + + model_list = [] + for idx, blocked in enumerate(blocked_flags): + model_list.append( + { + "model_name": "gpt-4o", + "litellm_params": { + "model": f"openai/gpt-4o-{idx}", + "api_key": "sk-fake-for-tests", + "use_in_pass_through": True, + }, + "model_info": {"id": f"pt-{idx}", "blocked": blocked}, + } + ) + return litellm.Router(model_list=model_list) + + +def test_get_available_deployment_for_pass_through_skips_blocked(): + router = _router_with_two_pass_through_deployments([True, False]) + deployment = router.get_available_deployment_for_pass_through( + model="gpt-4o", request_kwargs={} + ) + assert deployment["model_info"]["id"] == "pt-1" + + +def test_get_available_deployment_for_pass_through_raises_when_dict_blocked(): + from litellm.types.router import RouterRateLimitErrorBasic + + router = _router_with_two_pass_through_deployments([True, True]) + with pytest.raises(RouterRateLimitErrorBasic): + router.get_available_deployment_for_pass_through( + model="pt-0", request_kwargs={} + ) From e77434eb80071e6ba4b25338da959c91a8f1591a Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Thu, 14 May 2026 14:23:44 +0200 Subject: [PATCH 4/8] Document routing entry points covered by _filter_blocked_deployments --- litellm/router.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 6803ba5db2e8..3523a9d1ed03 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10729,8 +10729,11 @@ def _filter_blocked_deployments( """ Filters out deployments that an admin has paused via `LiteLLM_ProxyModelTable.blocked`. - Applied alongside the cooldown filter on both the primary routing path and the - retry / health-check helpers so paused deployments never serve a request. + Applied alongside the cooldown filter on every routing entry point that calls + `_common_checks_available_deployment` directly — the primary sync/async path, + the sync pass-through path, and the retry / health-check helpers — so paused + deployments never serve a request. The async pass-through path inherits this + filter through its delegation to `async_get_healthy_deployments`. """ return [ deployment From 6f09f771af48176811a951f5d0dd7ee9f8c52f5c Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Thu, 14 May 2026 15:33:23 +0200 Subject: [PATCH 5/8] fix(router): refuse credentials for blocked deployments Veria-AI's third finding: `get_deployment_credentials` and `get_deployment_credentials_with_provider` resolve credentials by model_id / model_name / wildcard without consulting the `blocked` flag, so passthrough file, batch, and vector-store endpoints can keep calling a paused deployment by passing the model name or an encoded resource ID. The router-level filter only covers chat/completion paths. - Add `Router._is_deployment_blocked(deployment)` static helper that reads `model_info.blocked` off a `Deployment` Pydantic instance. - `get_deployment_credentials` and `get_deployment_credentials_with_provider` now return `None` when the resolved deployment is blocked, matching the existing "not found in model_list" behavior so callers fail at the same boundary they already handle. Adds 2 tests confirming both helpers return None for the blocked deployment and unchanged credentials for the unblocked sibling. --- litellm/router.py | 22 +++++++++++++++++++--- tests/test_litellm/test_router.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 3523a9d1ed03..af3848316881 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8021,10 +8021,14 @@ def get_deployment(self, model_id: str) -> Optional[Deployment]: def get_deployment_credentials(self, model_id: str) -> Optional[dict]: """ - Returns -> dict of credentials for a given model id + Returns -> dict of credentials for a given model id. + + Returns None if the deployment is paused via `LiteLLM_ProxyModelTable.blocked`, + so file/batch/passthrough callers that resolve credentials directly cannot keep + using a paused deployment. """ deployment = self.get_deployment(model_id=model_id) - if deployment is None: + if deployment is None or self._is_deployment_blocked(deployment): return None return CredentialLiteLLMParams( **deployment.litellm_params.model_dump(exclude_none=True) @@ -8095,7 +8099,7 @@ def get_deployment_credentials_with_provider( elif isinstance(deployment_dict, Deployment): deployment = deployment_dict - if deployment is None: + if deployment is None or self._is_deployment_blocked(deployment): return None # Get basic credentials @@ -10741,6 +10745,18 @@ def _filter_blocked_deployments( if (deployment.get("model_info") or {}).get("blocked") is not True ] + @staticmethod + def _is_deployment_blocked(deployment: "Deployment") -> bool: + """ + Returns True when a `Deployment` Pydantic instance carries the admin-paused + flag. Used by credential-lookup helpers so passthrough file / batch endpoints + cannot bypass the pause by resolving credentials directly. + """ + model_info = getattr(deployment, "model_info", None) + if model_info is None: + return False + return getattr(model_info, "blocked", None) is True + async def _async_filter_health_check_unhealthy_deployments( self, healthy_deployments: List[Dict], diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index a1d2c4070ca0..02d24aded028 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3830,3 +3830,15 @@ def test_get_available_deployment_for_pass_through_raises_when_dict_blocked(): router.get_available_deployment_for_pass_through( model="pt-0", request_kwargs={} ) + + +def test_get_deployment_credentials_returns_none_for_blocked_deployment(): + router = _router_with_two_deployments([True, False]) + assert router.get_deployment_credentials(model_id="dep-0") is None + assert router.get_deployment_credentials(model_id="dep-1") is not None + + +def test_get_deployment_credentials_with_provider_returns_none_for_blocked_deployment(): + router = _router_with_two_deployments([True, False]) + assert router.get_deployment_credentials_with_provider(model_id="dep-0") is None + assert router.get_deployment_credentials_with_provider(model_id="dep-1") is not None From 1b0d64bdd6d0139266b9dfc4f22da2c246003d95 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Fri, 15 May 2026 11:52:58 +0200 Subject: [PATCH 6/8] Document Returns: None behavior for blocked deployments in credential helper --- litellm/router.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index af3848316881..484fd816e3ee 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8073,7 +8073,9 @@ def get_deployment_credentials_with_provider( Returns: Dictionary containing api_key, api_base, custom_llm_provider, etc. - Returns None if model not found. + Returns None if model not found, or if the resolved deployment is + paused via `LiteLLM_ProxyModelTable.blocked` (so passthrough callers + cannot bypass an admin pause by resolving credentials directly). Example: credentials = router.get_deployment_credentials_with_provider("gpt-4o-litellm") From e1f19a3bd4399c5357b5411a9688d59f4ca27233 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Fri, 15 May 2026 12:51:08 +0200 Subject: [PATCH 7/8] Document intent of LiteLLM_ProxyModelTable.blocked migration in SQL comment --- .../migration.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql index ce8fccdbddf5..3253b63a8843 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql @@ -1,2 +1,4 @@ -- AlterTable +-- Adds the admin-toggleable pause flag used by the router's blocked filter and the +-- credential lookup helpers; defaults to false so existing rows behave unchanged. ALTER TABLE "LiteLLM_ProxyModelTable" ADD COLUMN IF NOT EXISTS "blocked" BOOLEAN NOT NULL DEFAULT false; From 224d9348efc8dc9d76956f1396e0b36d2067b496 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Fri, 15 May 2026 13:14:41 +0200 Subject: [PATCH 8/8] fix: gate blocked flag on proxy-admin role + skip blocked in raw deployment lookup Veria-AI's two additional findings after the credential-helper fix: 1. Team admins authorized for team-scoped models via `can_user_make_model_call` could PATCH `{"blocked": false}` to resume a paused deployment because the `blocked` field was not separately gated. `patch_model` now rejects any update that carries `blocked` unless the caller has the `PROXY_ADMIN` role, raising a 403 ProxyException with `param="blocked"` before the DB write is built. 2. `route_llm_request.aroute_request` falls back to `Router.get_deployment_by_model_group_name` for the Evals / Realtime credential lookup when `get_deployment_credentials` returns None. The new `None` return for blocked deployments was bypassed by this fallback path, so a key holder could still resolve `litellm_params` for a paused deployment and call `/v1/evals` or `/realtime/client_secrets`. The fallback now checks `Router._is_deployment_blocked` before copying `litellm_params`. Tests: - `TestPatchModelBlockedAuthGate`: proxy admin can set blocked, team admin / internal user gets a 403 with `param="blocked"`. --- .../model_management_endpoints.py | 14 +++ litellm/proxy/route_llm_request.py | 6 +- .../test_model_management_endpoints.py | 98 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index f8c28c684cb0..472306eb818e 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -233,6 +233,20 @@ async def patch_model( premium_user=premium_user, ) + # Pause/resume (`blocked`) is a proxy-admin-only privilege. Team admins + # passed the auth check above for team-scoped models, but they must not + # be able to unblock (or block) a model their proxy admin has paused. + if ( + patch_data.blocked is not None + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN + ): + raise ProxyException( + message="Only proxy admins can change a model's blocked flag.", + type=ProxyErrorTypes.auth_error.value, + code=status.HTTP_403_FORBIDDEN, + param="blocked", + ) + # Handle team model updates with proper alias management update_data = await _update_team_model_in_db( db_model=db_model, diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index bfe6b8484faf..0dee21ee2818 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -430,7 +430,11 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin deployment = llm_router.get_deployment_by_model_group_name( model_group_name=model ) - if deployment and deployment.litellm_params: + if ( + deployment + and deployment.litellm_params + and not llm_router._is_deployment_blocked(deployment) + ): deployment_creds = deployment.litellm_params.model_dump( exclude_none=True ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 7068da8b21dd..b65f6305b77e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -1520,3 +1520,101 @@ def test_get_model_info_with_id_defaults_blocked_to_false_when_missing(self): model.model_info = {} info = ProxyConfig().get_model_info_with_id(model=model, db_model=True) assert getattr(info, "blocked") is False + + +class TestPatchModelBlockedAuthGate: + """Only proxy admins may flip `blocked` — team admins authorized for + team-scoped models via `can_user_make_model_call` must still be rejected + when they attempt to toggle the pause flag.""" + + @pytest.mark.asyncio + async def test_team_admin_cannot_toggle_blocked(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + patch_model, + ) + + non_admin = UserAPIKeyAuth( + user_id="team_admin", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + existing_row = MagicMock() + existing_row.litellm_params = {"model": "openai/gpt-4o-mini"} + existing_row.model_dump.return_value = { + "model_name": "gpt-4o-mini", + "litellm_params": existing_row.litellm_params, + "model_info": {"id": "m1"}, + } + existing_row.model_dump_json.return_value = "{}" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( + return_value=existing_row + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.llm_router", MagicMock()), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch("litellm.proxy.proxy_server.premium_user", True), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + ): + with pytest.raises(Exception) as exc_info: + await patch_model( + model_id="m1", + patch_data=updateDeployment(blocked=True), + user_api_key_dict=non_admin, + ) + err = exc_info.value + assert getattr(err, "param", "") == "blocked" + assert "proxy admin" in getattr(err, "message", "").lower() + + @pytest.mark.asyncio + async def test_proxy_admin_can_toggle_blocked(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + patch_model, + ) + + admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + existing_row = MagicMock() + existing_row.litellm_params = {"model": "openai/gpt-4o-mini"} + existing_row.model_dump.return_value = { + "model_name": "gpt-4o-mini", + "litellm_params": existing_row.litellm_params, + "model_info": {"id": "m1"}, + } + existing_row.model_dump_json.return_value = "{}" + updated_row = MagicMock() + updated_row.model_dump_json.return_value = "{}" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( + return_value=existing_row + ) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock( + return_value=updated_row + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.llm_router", MagicMock()), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch("litellm.proxy.proxy_server.premium_user", True), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + patch( + "litellm.proxy.management_endpoints.model_management_endpoints.clear_cache", + new=AsyncMock(return_value=None), + ), + ): + result = await patch_model( + model_id="m1", + patch_data=updateDeployment(blocked=True), + user_api_key_dict=admin, + ) + assert result is updated_row + mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()