feat(proxy): add opt-in flag to require rpm/tpm for project models (create + update)

This commit is contained in:
ansh-agrawal 2026-08-11 11:53:39 +05:30
parent b0fac57fe4
commit 67c4eb86b1
2 changed files with 267 additions and 0 deletions

View file

@ -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)

View file

@ -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)