Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +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;
5 changes: 3 additions & 2 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions litellm/proxy/management_endpoints/model_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
veria-ai[bot] marked this conversation as resolved.
prisma_compatible_model_dict["blocked"] = updated_patch.blocked

return prisma_compatible_model_dict


Expand Down Expand Up @@ -230,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,
Expand Down
13 changes: 13 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion litellm/proxy/route_llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
5 changes: 3 additions & 2 deletions litellm/proxy/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
102 changes: 88 additions & 14 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
List,
Literal,
Optional,
Set,
Tuple,
Union,
cast,
Expand Down Expand Up @@ -6834,12 +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
else:
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

Expand Down Expand Up @@ -6867,10 +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"] not in unhealthy_deployments_set:
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):
Expand Down Expand Up @@ -8019,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.
Comment thread
veria-ai[bot] marked this conversation as resolved.
"""
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)
Expand Down Expand Up @@ -8067,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")
Expand All @@ -8093,7 +8101,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
Expand Down Expand Up @@ -9120,6 +9128,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]:
Expand Down Expand Up @@ -10006,6 +10037,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)
Expand Down Expand Up @@ -10039,6 +10072,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,
Expand Down Expand Up @@ -10419,6 +10454,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(
Expand Down Expand Up @@ -10449,6 +10486,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(
Expand Down Expand Up @@ -10557,6 +10596,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
Expand Down Expand Up @@ -10596,6 +10637,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:
Expand Down Expand Up @@ -10685,6 +10729,36 @@ def _filter_cooldown_deployments(
if deployment["model_info"]["id"] not in cooldown_set
]

def _filter_blocked_deployments(
Comment thread
veria-ai[bot] marked this conversation as resolved.
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 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
for deployment in healthy_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],
Expand Down
4 changes: 4 additions & 0 deletions litellm/types/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -323,6 +326,7 @@ class updateDeployment(BaseModel):
model_name: Optional[str] = None
litellm_params: Optional[updateLiteLLMParams] = None
model_info: Optional[ModelInfo] = None
blocked: Optional[bool] = None
Comment thread
greptile-apps[bot] marked this conversation as resolved.

model_config = ConfigDict(protected_namespaces=())

Expand Down
5 changes: 3 additions & 2 deletions schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading