diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 66fac8d76ee..9249150f6ce 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -180,6 +180,75 @@ def _check_team_project_limits( ) +def _project_models_missing_positive_quota( + models: list[str] | None, + rpm_limits: Mapping[str, object] | None, + tpm_limits: Mapping[str, object] | None, +) -> list[str]: + """Return the models that lack a positive `rpm` AND `tpm` quota. + + A valid quota is a positive integer; null, zero, and negative are rejected + because downstream rate limiters treat a non-positive limit as immediately + exhausted (every request blocked). + """ + + def _is_positive(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + rpm = rpm_limits or {} + tpm = tpm_limits or {} + return [model for model in (models or []) if not _is_positive(rpm.get(model)) or not _is_positive(tpm.get(model))] + + +def _raise_on_missing_project_model_quota(data: NewProjectRequest | UpdateProjectRequest) -> None: + """Require a positive `rpm`/`tpm` quota for every model on project CREATE. + + `model_rpm_limit`/`model_tpm_limit` are relocated into `metadata` by the request + model's `set_model_info` validator, so they are read from there. + + Only invoked when `general_settings.enforce_project_model_quota` is enabled + (default off), so it is opt-in and does not change behavior for existing users. + """ + metadata = data.metadata or {} + missing = _project_models_missing_positive_quota( + data.models, metadata.get("model_rpm_limit"), metadata.get("model_tpm_limit") + ) + if not missing: + return + raise HTTPException( + status_code=400, + detail={ + "error": f"models {missing} added to project without a positive rpm/tpm quota. Set a positive model_rpm_limit and model_tpm_limit for each model." + }, + ) + + +def _raise_on_missing_project_model_quota_on_update(data: UpdateProjectRequest, existing_project: object) -> None: + """Require a positive `rpm`/`tpm` quota over the RESULTING state on project UPDATE. + + `/project/update` replaces `models` and `metadata` when they are provided, so the + check runs on what the project WILL look like: a partial update that doesn't touch + models/quota keeps the existing values, while one that adds a model or clears a + model's quota must leave every resulting model with a positive limit. + + Only invoked when `general_settings.enforce_project_model_quota` is enabled + (default off), so it is opt-in and does not change behavior for existing users. + """ + resulting_models = data.models if data.models is not None else (getattr(existing_project, "models", None) or []) + resulting_metadata = data.metadata if data.metadata is not None else (getattr(existing_project, "metadata", None) or {}) + missing = _project_models_missing_positive_quota( + resulting_models, resulting_metadata.get("model_rpm_limit"), resulting_metadata.get("model_tpm_limit") + ) + if not missing: + return + raise HTTPException( + status_code=400, + detail={ + "error": f"models {missing} would be left on the project without a positive rpm/tpm quota. Set a positive model_rpm_limit and model_tpm_limit for each model." + }, + ) + + async def _create_budget_for_project( data: NewProjectRequest, user_id: str | None, @@ -327,6 +396,7 @@ async def new_project( ``` """ from litellm.proxy.proxy_server import ( + general_settings, litellm_proxy_admin_name, premium_user, prisma_client, @@ -374,6 +444,10 @@ async def new_project( data=data, ) + # Opt-in (default off): require rpm/tpm for every model added to the project. + if general_settings.get("enforce_project_model_quota", False): + _raise_on_missing_project_model_quota(data) + # Check if user has permission to create projects for this team # only team admins can create projects for their team has_permission = await _check_user_permission_for_project( @@ -512,6 +586,7 @@ async def update_project( ``` """ from litellm.proxy.proxy_server import ( + general_settings, litellm_proxy_admin_name, premium_user, prisma_client, @@ -616,6 +691,10 @@ async def update_project( data=data, ) + # Opt-in (default off): require rpm/tpm for every model the update would leave on the project. + if general_settings.get("enforce_project_model_quota", False): + _raise_on_missing_project_model_quota_on_update(data, existing_project) + # Prepare update data update_data = data.json(exclude_none=True, exclude={"project_id"}) update_data = prisma_client.jsonify_object(update_data) diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c29b4c68bb0..f4ac8a55092 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1039,3 +1039,191 @@ async def test_project_eviction_publishes_cross_worker_invalidation(monkeypatch) ) mock_publish.assert_awaited_once_with(cache_key=f"project_id:{project_id}") + + +def test_enforce_project_model_quota_missing_both_raises(): + """A model added to a project without rpm/tpm is rejected.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest(team_id="test-team", models=["gpt-5.5"]) + with pytest.raises(Exception) as exc_info: + _raise_on_missing_project_model_quota(data) + assert "gpt-5.5" in str(exc_info.value.detail) + assert "rpm/tpm quota" in str(exc_info.value.detail) + + +def test_enforce_project_model_quota_missing_tpm_raises(): + """A model with rpm but no tpm is rejected.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest( + team_id="test-team", + models=["gpt-5.5"], + model_rpm_limit={"gpt-5.5": 100}, + ) + with pytest.raises(Exception): + _raise_on_missing_project_model_quota(data) + + +def test_enforce_project_model_quota_all_present_passes(): + """A model with both rpm and tpm set passes.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest( + team_id="test-team", + models=["gpt-5.5"], + model_rpm_limit={"gpt-5.5": 100}, + model_tpm_limit={"gpt-5.5": 1000}, + ) + # Should not raise. + _raise_on_missing_project_model_quota(data) + + +def test_enforce_project_model_quota_no_models_passes(): + """A project with no models has nothing to enforce.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest(team_id="test-team") + # Should not raise. + _raise_on_missing_project_model_quota(data) + + +def test_enforce_project_model_quota_zero_rejected(): + """A zero quota is non-positive -> rejected (downstream treats it as exhausted).""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest( + team_id="test-team", + models=["gpt-5.5"], + model_rpm_limit={"gpt-5.5": 0}, + model_tpm_limit={"gpt-5.5": 1000}, + ) + with pytest.raises(Exception): + _raise_on_missing_project_model_quota(data) + + +def test_enforce_project_model_quota_negative_rejected(): + """A negative quota is non-positive -> rejected.""" + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota, + ) + + data = NewProjectRequest( + team_id="test-team", + models=["gpt-5.5"], + model_rpm_limit={"gpt-5.5": 100}, + model_tpm_limit={"gpt-5.5": -1}, + ) + with pytest.raises(Exception): + _raise_on_missing_project_model_quota(data) + + +def test_update_quota_adds_model_without_quota_rejected(): + """Adding a model via /project/update without quota is rejected (the bypass).""" + import types + + from litellm.proxy._types import UpdateProjectRequest + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota_on_update, + ) + + existing = types.SimpleNamespace(models=[], metadata={}) + data = UpdateProjectRequest(project_id="p", models=["gpt-5.5"]) # adds model, no quota + with pytest.raises(Exception): + _raise_on_missing_project_model_quota_on_update(data, existing) + + +def test_update_quota_adds_model_with_quota_passes(): + """Adding a model with a positive quota via update passes.""" + import types + + from litellm.proxy._types import UpdateProjectRequest + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota_on_update, + ) + + existing = types.SimpleNamespace(models=[], metadata={}) + data = UpdateProjectRequest( + project_id="p", + models=["gpt-5.5"], + model_rpm_limit={"gpt-5.5": 100}, + model_tpm_limit={"gpt-5.5": 1000}, + ) + # Should not raise. + _raise_on_missing_project_model_quota_on_update(data, existing) + + +def test_update_quota_partial_update_keeps_existing_valid_passes(): + """A partial update that doesn't touch models/quota keeps existing valid quota -> passes.""" + import types + + from litellm.proxy._types import UpdateProjectRequest + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota_on_update, + ) + + existing = types.SimpleNamespace( + models=["gpt-5.5"], + metadata={"model_rpm_limit": {"gpt-5.5": 100}, "model_tpm_limit": {"gpt-5.5": 1000}}, + ) + data = UpdateProjectRequest(project_id="p", description="unrelated change") + # Should not raise (existing quota is valid, update doesn't touch it). + _raise_on_missing_project_model_quota_on_update(data, existing) + + +def test_update_quota_existing_quotaless_model_rejected(): + """A project already holding a quota-less model is rejected on any update (fail-closed).""" + import types + + from litellm.proxy._types import UpdateProjectRequest + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _raise_on_missing_project_model_quota_on_update, + ) + + existing = types.SimpleNamespace(models=["gpt-5.5"], metadata={}) + data = UpdateProjectRequest(project_id="p", description="unrelated change") + with pytest.raises(Exception): + _raise_on_missing_project_model_quota_on_update(data, existing) + + +@pytest.mark.asyncio +async def test_new_project_flag_on_missing_rpm_tpm_returns_400(): + """End-to-end: with the flag on, POST /project/new rejects a model added without rpm/tpm.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from fastapi import Request + + from litellm.proxy._types import LiteLLM_TeamTable + from litellm_enterprise.proxy.management_endpoints import project_endpoints as pe + + team = LiteLLM_TeamTable(team_id="test-team", models=["gpt-5.5"]) + data = NewProjectRequest(team_id="test-team", models=["gpt-5.5"]) # no rpm/tpm + + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.premium_user", True), + patch("litellm.proxy.proxy_server.general_settings", {"enforce_project_model_quota": True}), + patch.object(pe, "_validate_team_exists", AsyncMock(return_value=team)), + patch.object(pe, "_check_user_permission_for_project", AsyncMock(return_value=True)), + ): + with pytest.raises(Exception) as exc_info: + await pe.new_project( + data=data, + http_request=Request(scope={"type": "http"}), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", user_id="1234" + ), + ) + + # new_project re-wraps the HTTPException, so assert on the string form. + assert "rpm/tpm quota" in str(exc_info.value)