mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(proxy): add opt-in flag to require rpm/tpm for project models (create + update)
This commit is contained in:
parent
b0fac57fe4
commit
67c4eb86b1
2 changed files with 267 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue