Merge pull request #15120 from BerriAI/litellm_dev_10_01_2025_p2

(feat) Support 'guaranteed_throughput' when setting limits on keys belonging to a team
This commit is contained in:
Krish Dholakia
2025-10-04 15:22:47 -07:00
committed by GitHub
6 changed files with 938 additions and 86 deletions
+9
View File
@@ -731,6 +731,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
metadata: Optional[dict] = {}
tpm_limit: Optional[int] = None
rpm_limit: Optional[int] = None
budget_duration: Optional[str] = None
allowed_cache_controls: Optional[list] = []
config: Optional[dict] = {}
@@ -755,6 +756,12 @@ class KeyRequestBase(GenerateRequestBase):
tags: Optional[List[str]] = None
enforced_params: Optional[List[str]] = None
allowed_routes: Optional[list] = []
rpm_limit_type: Optional[
Literal["guaranteed_throughput", "best_effort_throughput"]
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating rpm
tpm_limit_type: Optional[
Literal["guaranteed_throughput", "best_effort_throughput"]
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm
class LiteLLMKeyType(str, enum.Enum):
@@ -3056,6 +3063,8 @@ class PassThroughEndpointLoggingTypedDict(TypedDict):
LiteLLM_ManagementEndpoint_MetadataFields = [
"model_rpm_limit",
"model_tpm_limit",
"rpm_limit_type",
"tpm_limit_type",
"guardrails",
"tags",
"enforced_params",
@@ -27,6 +27,7 @@ from litellm.caching import DualCache
from litellm.constants import LENGTH_OF_LITELLM_GENERATED_KEY, UI_SESSION_TOKEN_TEAM_ID
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy._types import *
from litellm.proxy._types import LiteLLM_VerificationToken
from litellm.proxy.auth.auth_checks import (
_cache_key_object,
_delete_cache_key_object,
@@ -549,6 +550,15 @@ async def _common_key_generation_helper( # noqa: PLR0915
value=getattr(data, field),
)
for field in LiteLLM_ManagementEndpoint_MetadataFields:
if getattr(data, field, None) is not None:
_set_object_metadata_field(
object_data=data,
field_name=field,
value=getattr(data, field),
)
delattr(data, field)
data_json = data.model_dump(exclude_unset=True, exclude_none=True) # type: ignore
data_json = handle_key_type(data, data_json)
@@ -627,6 +637,153 @@ async def _common_key_generation_helper( # noqa: PLR0915
return response
def check_team_key_model_specific_limits(
keys: List[LiteLLM_VerificationToken],
team_table: LiteLLM_TeamTableCachedObj,
data: Union[GenerateKeyRequest, UpdateKeyRequest],
) -> None:
"""
Check if the team key is allocating model specific limits. If so, raise an error if we're overallocating.
"""
if data.model_rpm_limit is None and data.model_tpm_limit is None:
return
# get total model specific tpm/rpm limit
model_specific_rpm_limit: Dict[str, int] = {}
model_specific_tpm_limit: Dict[str, int] = {}
for key in keys:
if key.metadata.get("model_rpm_limit", None) is not None:
for model, rpm_limit in key.metadata.get("model_rpm_limit", {}).items():
model_specific_rpm_limit[model] = (
model_specific_rpm_limit.get(model, 0) + rpm_limit
)
if key.metadata.get("model_tpm_limit", None) is not None:
for model, tpm_limit in key.metadata.get("model_tpm_limit", {}).items():
model_specific_tpm_limit[model] = (
model_specific_tpm_limit.get(model, 0) + tpm_limit
)
if data.model_rpm_limit is not None:
for model, rpm_limit in data.model_rpm_limit.items():
if (
model_specific_rpm_limit.get(model, 0) + rpm_limit
> team_table.rpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated RPM limit={model_specific_rpm_limit.get(model, 0)} + Key RPM limit={rpm_limit} is greater than team RPM limit={team_table.rpm_limit}",
)
elif team_table.metadata and team_table.metadata.get("model_rpm_limit"):
team_model_specific_rpm_limit_dict = team_table.metadata.get(
"model_rpm_limit", {}
)
team_model_specific_rpm_limit = team_model_specific_rpm_limit_dict.get(
model
)
if (
model_specific_rpm_limit.get(model, 0) + rpm_limit
> team_model_specific_rpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated RPM limit={model_specific_rpm_limit.get(model, 0)} + Key RPM limit={rpm_limit} is greater than team RPM limit={team_model_specific_rpm_limit.get(model, 0)}",
)
if data.model_tpm_limit is not None:
for model, tpm_limit in data.model_tpm_limit.items():
if (
team_table.tpm_limit is not None
and model_specific_tpm_limit.get(model, 0) + tpm_limit
> team_table.tpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated TPM limit={model_specific_tpm_limit.get(model, 0)} + Key TPM limit={tpm_limit} is greater than team TPM limit={team_table.tpm_limit}",
)
elif team_table.metadata and team_table.metadata.get("model_tpm_limit"):
team_model_specific_tpm_limit_dict = team_table.metadata.get(
"model_tpm_limit", {}
)
team_model_specific_tpm_limit = team_model_specific_tpm_limit_dict.get(
model
)
if (
team_model_specific_tpm_limit
and model_specific_tpm_limit.get(model, 0) + tpm_limit
> team_model_specific_tpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated TPM limit={model_specific_tpm_limit.get(model, 0)} + Key TPM limit={tpm_limit} is greater than team TPM limit={team_model_specific_tpm_limit}",
)
def check_team_key_rpm_tpm_limits(
keys: List[LiteLLM_VerificationToken],
team_table: LiteLLM_TeamTableCachedObj,
data: Union[GenerateKeyRequest, UpdateKeyRequest],
) -> None:
"""
Check if the team key is allocating rpm/tpm limits. If so, raise an error if we're overallocating.
"""
if keys is not None and len(keys) > 0:
allocated_tpm = sum(key.tpm_limit for key in keys if key.tpm_limit is not None)
allocated_rpm = sum(key.rpm_limit for key in keys if key.rpm_limit is not None)
else:
allocated_tpm = 0
allocated_rpm = 0
if (
data.tpm_limit is not None
and team_table.tpm_limit is not None
and data.tpm_limit + allocated_tpm > team_table.tpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated TPM limit={allocated_tpm} + Key TPM limit={data.tpm_limit} is greater than team TPM limit={team_table.tpm_limit}",
)
if (
data.rpm_limit is not None
and team_table.rpm_limit is not None
and data.rpm_limit + allocated_rpm > team_table.rpm_limit
):
raise HTTPException(
status_code=400,
detail=f"Allocated RPM limit={allocated_rpm} + Key RPM limit={data.rpm_limit} is greater than team RPM limit={team_table.rpm_limit}",
)
async def _check_team_key_limits(
team_table: LiteLLM_TeamTableCachedObj,
data: Union[GenerateKeyRequest, UpdateKeyRequest],
prisma_client: PrismaClient,
) -> None:
"""
Check if the team key is allocating guaranteed throughput limits. If so, raise an error if we're overallocating.
Only runs check if tpm_limit_type or rpm_limit_type is "guaranteed_throughput"
"""
if (
data.tpm_limit_type != "guaranteed_throughput"
and data.rpm_limit_type != "guaranteed_throughput"
):
return
# get all team keys
# calculate allocated tpm/rpm limit
# check if specified tpm/rpm limit is greater than allocated tpm/rpm limit
keys = await prisma_client.db.litellm_verificationtoken.find_many(
where={"team_id": team_table.team_id},
)
check_team_key_model_specific_limits(
keys=keys,
team_table=team_table,
data=data,
)
check_team_key_rpm_tpm_limits(
keys=keys,
team_table=team_table,
data=data,
)
@router.post(
"/key/generate",
tags=["key management"],
@@ -668,6 +825,8 @@ async def generate_key_fn(
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
- model_rpm_limit: Optional[dict] - key-specific model rpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific rpm limit.
- model_tpm_limit: Optional[dict] - key-specific model tpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific tpm limit.
- tpm_limit_type: Optional[str] - Type of tpm limit. Options: "best_effort_throughput" (no error if we're overallocating tpm), "guaranteed_throughput" (raise an error if we're overallocating tpm). Defaults to "best_effort_throughput".
- rpm_limit_type: Optional[str] - Type of rpm limit. Options: "best_effort_throughput" (no error if we're overallocating rpm), "guaranteed_throughput" (raise an error if we're overallocating rpm). Defaults to "best_effort_throughput".
- allowed_cache_controls: Optional[list] - List of allowed cache control values. Example - ["no-cache", "no-store"]. See all values - https://docs.litellm.ai/docs/proxy/caching#turn-on--off-caching-per-request
- blocked: Optional[bool] - Whether the key is blocked.
- rpm_limit: Optional[int] - Specify rpm limit for a given key (Requests per minute)
@@ -703,12 +862,19 @@ async def generate_key_fn(
- user_id: (str) Unique user id - used for tracking spend across multiple keys for same user id.
"""
try:
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.proxy_server import (
prisma_client,
user_api_key_cache,
user_custom_key_generate,
)
if prisma_client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
verbose_proxy_logger.debug("entered /key/generate")
if user_custom_key_generate is not None:
@@ -736,7 +902,6 @@ async def generate_key_fn(
verbose_proxy_logger.debug(
f"Error getting team object in `/key/generate`: {e}"
)
team_table = None
key_generation_check(
team_table=team_table,
@@ -745,12 +910,21 @@ async def generate_key_fn(
route=KeyManagementRoutes.KEY_GENERATE,
)
if team_table is not None:
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=prisma_client,
)
return await _common_key_generation_helper(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
team_table=team_table,
)
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.generate_key_fn(): Exception occured - {}".format(
@@ -804,6 +978,8 @@ async def generate_service_account_key_fn(
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
- model_rpm_limit: Optional[dict] - key-specific model rpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific rpm limit.
- model_tpm_limit: Optional[dict] - key-specific model tpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific tpm limit.
- tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput" or "guaranteed_throughput"
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput" or "guaranteed_throughput"
- allowed_cache_controls: Optional[list] - List of allowed cache control values. Example - ["no-cache", "no-store"]. See all values - https://docs.litellm.ai/docs/proxy/caching#turn-on--off-caching-per-request
- blocked: Optional[bool] - Whether the key is blocked.
- rpm_limit: Optional[int] - Specify rpm limit for a given key (Requests per minute)
@@ -832,12 +1008,19 @@ async def generate_service_account_key_fn(
- user_id: (str) Unique user id - used for tracking spend across multiple keys for same user id.
"""
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.proxy_server import (
prisma_client,
user_api_key_cache,
user_custom_key_generate,
)
if prisma_client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
await validate_team_id_used_in_service_account_request(
team_id=data.team_id,
prisma_client=prisma_client,
@@ -870,6 +1053,13 @@ async def generate_service_account_key_fn(
)
team_table = None
if team_table is not None:
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=prisma_client,
)
key_generation_check(
team_table=team_table,
user_api_key_dict=user_api_key_dict,
@@ -1096,6 +1286,8 @@ async def update_key_fn(
- rpm_limit: Optional[int] - Requests per minute limit
- model_rpm_limit: Optional[dict] - Model-specific RPM limits {"gpt-4": 100, "claude-v1": 200}
- model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
- tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput" or "guaranteed_throughput"
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput" or "guaranteed_throughput"
- allowed_cache_controls: Optional[list] - List of allowed cache control values
- duration: Optional[str] - Key validity duration ("30d", "1h", etc.)
- permissions: Optional[dict] - Key-specific permissions
@@ -1170,14 +1362,25 @@ async def update_key_fn(
user_api_key_cache=user_api_key_cache,
)
# if team change - check if this is possible
if is_different_team(data=data, existing_key_row=existing_key_row):
# Only check team limits if key has a team_id
team_obj: Optional[LiteLLM_TeamTableCachedObj] = None
if data.team_id is not None:
team_obj = await get_team_object(
team_id=cast(str, data.team_id),
team_id=data.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
await _check_team_key_limits(
team_table=team_obj,
data=data,
prisma_client=prisma_client,
)
# if team change - check if this is possible
if is_different_team(data=data, existing_key_row=existing_key_row):
if llm_router is None:
raise HTTPException(
status_code=400,
@@ -1185,6 +1388,14 @@ async def update_key_fn(
"error": "LLM router not found. Please set it up by passing in a valid config.yaml or adding models via the UI."
},
)
# team_obj should be set since is_different_team() returns True only when data.team_id is not None
if team_obj is None:
raise HTTPException(
status_code=500,
detail={
"error": "Team object not found for team change validation"
},
)
validate_key_team_change(
key=existing_key_row,
team=team_obj,
@@ -15,14 +15,17 @@ from fastapi import HTTPException
from litellm.proxy._types import (
GenerateKeyRequest,
LiteLLM_TeamTableCachedObj,
LiteLLM_VerificationToken,
LitellmUserRoles,
UpdateKeyRequest,
)
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_team_key_limits,
_common_key_generation_helper,
_list_key_helper,
check_team_key_model_specific_limits,
generate_key_helper_fn,
prepare_key_update_data,
validate_key_team_change,
@@ -847,17 +850,24 @@ async def test_generate_service_account_key_endpoint_validation():
)
# Test case 1: Missing team_id
with pytest.raises(HTTPException) as exc_info:
await generate_service_account_key_fn(
data=GenerateKeyRequest(team_id=None),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1"
),
litellm_changed_by=None,
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
# Mock prisma_client to be not None so we can reach team_id validation
mock_prisma_instance = AsyncMock()
mock_prisma.return_value = mock_prisma_instance
assert exc_info.value.status_code == 400
assert "team_id is required for service account keys" in str(exc_info.value.detail)
with pytest.raises(HTTPException) as exc_info:
await generate_service_account_key_fn(
data=GenerateKeyRequest(team_id=None),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1"
),
litellm_changed_by=None,
)
assert exc_info.value.status_code == 400
assert "team_id is required for service account keys" in str(
exc_info.value.detail
)
# Test case 2: Team doesn't exist in database
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
@@ -1040,7 +1050,7 @@ async def test_unblock_key_invalid_key_format(monkeypatch):
def test_validate_key_team_change_with_member_permissions():
"""
Test validate_key_team_change function with team member permissions.
This test covers the new logic that allows team members with specific
permissions to update keys, not just team admins.
"""
@@ -1054,111 +1064,107 @@ def test_validate_key_team_change_with_member_permissions():
mock_key.models = ["gpt-4"]
mock_key.tpm_limit = None
mock_key.rpm_limit = None
mock_team = MagicMock()
mock_team.team_id = "test-team-456"
mock_team.team_id = "test-team-456"
mock_team.members_with_roles = []
mock_team.tpm_limit = None
mock_team.rpm_limit = None
mock_change_initiator = MagicMock()
mock_change_initiator.user_id = "test-user-123"
mock_router = MagicMock()
# Mock the member object returned by _get_user_in_team
mock_member_object = MagicMock()
with patch('litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model'):
with patch('litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team') as mock_get_user:
with patch('litellm.proxy.management_endpoints.key_management_endpoints._is_user_team_admin') as mock_is_admin:
with patch('litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.does_team_member_have_permissions_for_endpoint') as mock_has_perms:
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model"
):
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team"
) as mock_get_user:
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints._is_user_team_admin"
) as mock_is_admin:
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.does_team_member_have_permissions_for_endpoint"
) as mock_has_perms:
mock_get_user.return_value = mock_member_object
mock_is_admin.return_value = False
mock_has_perms.return_value = True
# This should not raise an exception due to member permissions
validate_key_team_change(
key=mock_key,
team=mock_team,
change_initiated_by=mock_change_initiator,
llm_router=mock_router
llm_router=mock_router,
)
# Verify the permission check was called with correct parameters
mock_has_perms.assert_called_once_with(
team_member_object=mock_member_object,
team_table=mock_team,
route=KeyManagementRoutes.KEY_UPDATE.value
route=KeyManagementRoutes.KEY_UPDATE.value,
)
def test_key_rotation_fields_helper():
"""
Test the key data update logic for rotation fields.
This test focuses on the core logic that adds rotation fields to key_data
when auto_rotate is enabled, without the complexity of full key generation.
"""
# Test Case 1: With rotation enabled
key_data = {
"models": ["gpt-3.5-turbo"],
"user_id": "test-user"
}
key_data = {"models": ["gpt-3.5-turbo"], "user_id": "test-user"}
auto_rotate = True
rotation_interval = "30d"
# Simulate the rotation logic from generate_key_helper_fn
if auto_rotate and rotation_interval:
key_data.update({
"auto_rotate": auto_rotate,
"rotation_interval": rotation_interval
})
key_data.update(
{"auto_rotate": auto_rotate, "rotation_interval": rotation_interval}
)
# Verify rotation fields are added
assert key_data["auto_rotate"] == True
assert key_data["rotation_interval"] == "30d"
assert key_data["models"] == ["gpt-3.5-turbo"] # Original fields preserved
# Test Case 2: Without rotation enabled
key_data2 = {
"models": ["gpt-4"],
"user_id": "test-user"
}
key_data2 = {"models": ["gpt-4"], "user_id": "test-user"}
auto_rotate2 = False
rotation_interval2 = None
# Simulate the rotation logic
if auto_rotate2 and rotation_interval2:
key_data2.update({
"auto_rotate": auto_rotate2,
"rotation_interval": rotation_interval2
})
key_data2.update(
{"auto_rotate": auto_rotate2, "rotation_interval": rotation_interval2}
)
# Verify rotation fields are NOT added
assert "auto_rotate" not in key_data2
assert "rotation_interval" not in key_data2
assert key_data2["models"] == ["gpt-4"] # Original fields preserved
# Test Case 3: auto_rotate=True but no interval
key_data3 = {
"models": ["claude-3"],
"user_id": "test-user"
}
key_data3 = {"models": ["claude-3"], "user_id": "test-user"}
auto_rotate3 = True
rotation_interval3 = None
# Simulate the rotation logic
if auto_rotate3 and rotation_interval3:
key_data3.update({
"auto_rotate": auto_rotate3,
"rotation_interval": rotation_interval3
})
key_data3.update(
{"auto_rotate": auto_rotate3, "rotation_interval": rotation_interval3}
)
# Verify rotation fields are NOT added (missing interval)
assert "auto_rotate" not in key_data3
assert "rotation_interval" not in key_data3
@@ -1181,27 +1187,24 @@ async def test_update_key_fn_auto_rotate_enable():
team_id=None,
auto_rotate=False,
rotation_interval=None,
metadata={}
metadata={},
)
# Test enabling auto rotation
update_request = UpdateKeyRequest(
key="test-token",
auto_rotate=True,
rotation_interval="30d"
key="test-token", auto_rotate=True, rotation_interval="30d"
)
result = await prepare_key_update_data(
data=update_request,
existing_key_row=existing_key
data=update_request, existing_key_row=existing_key
)
# Verify rotation fields are included
assert result["auto_rotate"] is True
assert result["rotation_interval"] == "30d"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_key_fn_auto_rotate_disable():
"""Test that update_key_fn properly handles disabling auto rotation."""
from litellm.proxy._types import LiteLLM_VerificationToken, UpdateKeyRequest
@@ -1218,19 +1221,520 @@ async def test_update_key_fn_auto_rotate_disable():
team_id=None,
auto_rotate=True,
rotation_interval="30d",
metadata={}
metadata={},
)
# Test disabling auto rotation
update_request = UpdateKeyRequest(
key="test-token",
auto_rotate=False
)
update_request = UpdateKeyRequest(key="test-token", auto_rotate=False)
result = await prepare_key_update_data(
data=update_request,
existing_key_row=existing_key
data=update_request, existing_key_row=existing_key
)
# Verify auto_rotate is set to False
assert result["auto_rotate"] is False
@pytest.mark.asyncio
async def test_check_team_key_limits_no_existing_keys():
"""
Test _check_team_key_limits when team has no existing keys.
Should allow any TPM/RPM limits within team bounds.
"""
# Mock prisma client
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-123",
team_alias="test-team",
tpm_limit=10000,
rpm_limit=1000,
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request with limits within team bounds
data = GenerateKeyRequest(
tpm_limit=5000,
rpm_limit=500,
tpm_limit_type="guaranteed_throughput",
rpm_limit_type="guaranteed_throughput",
)
# Should not raise any exception
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
# Verify database was queried
mock_prisma_client.db.litellm_verificationtoken.find_many.assert_called_once_with(
where={"team_id": "test-team-123"}
)
@pytest.mark.asyncio
async def test_check_team_key_limits_with_existing_keys_within_bounds():
"""
Test _check_team_key_limits when team has existing keys but total allocation
is still within team limits.
"""
# Create mock existing keys
existing_key1 = MagicMock()
existing_key1.tpm_limit = 3000
existing_key1.rpm_limit = 200
existing_key2 = MagicMock()
existing_key2.tpm_limit = 2000
existing_key2.rpm_limit = 300
existing_key3 = MagicMock()
existing_key3.tpm_limit = None # Should be ignored in calculation
existing_key3.rpm_limit = None # Should be ignored in calculation
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key1, existing_key2, existing_key3]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-456",
team_alias="test-team",
tpm_limit=10000, # Total: 3000 + 2000 + 4000 (new) = 9000 < 10000 ✓
rpm_limit=1000, # Total: 200 + 300 + 400 (new) = 900 < 1000 ✓
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request that would still be within bounds
data = GenerateKeyRequest(
tpm_limit=4000,
rpm_limit=400,
)
# Should not raise any exception
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
@pytest.mark.asyncio
async def test_check_team_key_limits_tpm_overallocation():
"""
Test _check_team_key_limits when new key would cause TPM overallocation.
Should raise HTTPException with appropriate error message.
"""
# Create mock existing keys with high TPM usage
existing_key1 = MagicMock()
existing_key1.tpm_limit = 6000
existing_key1.rpm_limit = 100
existing_key1.metadata = {}
existing_key2 = MagicMock()
existing_key2.tpm_limit = 3000
existing_key2.rpm_limit = 200
existing_key2.metadata = {}
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key1, existing_key2]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-789",
team_alias="test-team",
tpm_limit=10000, # Allocated: 6000 + 3000 = 9000, New: 2000, Total: 11000 > 10000 ✗
rpm_limit=1000,
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request that would exceed TPM limits
data = GenerateKeyRequest(
tpm_limit=2000,
rpm_limit=100,
tpm_limit_type="guaranteed_throughput",
)
# Should raise HTTPException for TPM overallocation
with pytest.raises(HTTPException) as exc_info:
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
assert exc_info.value.status_code == 400
assert (
"Allocated TPM limit=9000 + Key TPM limit=2000 is greater than team TPM limit=10000"
in str(exc_info.value.detail)
)
@pytest.mark.asyncio
async def test_check_team_key_limits_rpm_overallocation():
"""
Test _check_team_key_limits when new key would cause RPM overallocation.
Should raise HTTPException with appropriate error message.
"""
# Create mock existing keys with high RPM usage
existing_key1 = MagicMock()
existing_key1.tpm_limit = 1000
existing_key1.rpm_limit = 600
existing_key1.metadata = {}
existing_key2 = MagicMock()
existing_key2.tpm_limit = 2000
existing_key2.rpm_limit = 300
existing_key2.metadata = {}
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key1, existing_key2]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-101",
team_alias="test-team",
tpm_limit=10000,
rpm_limit=1000, # Allocated: 600 + 300 = 900, New: 200, Total: 1100 > 1000 ✗
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request that would exceed RPM limits
data = GenerateKeyRequest(
tpm_limit=1000,
rpm_limit=200,
rpm_limit_type="guaranteed_throughput",
)
# Should raise HTTPException for RPM overallocation
with pytest.raises(HTTPException) as exc_info:
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
assert exc_info.value.status_code == 400
assert (
"Allocated RPM limit=900 + Key RPM limit=200 is greater than team RPM limit=1000"
in str(exc_info.value.detail)
)
@pytest.mark.asyncio
async def test_check_team_key_limits_no_team_limits():
"""
Test _check_team_key_limits when team has no TPM/RPM limits set.
Should allow any key limits since there are no team constraints.
"""
# Create mock existing keys
existing_key = MagicMock()
existing_key.tpm_limit = 5000
existing_key.rpm_limit = 500
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key]
)
# Create team table with no limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-202",
team_alias="test-team",
tpm_limit=None, # No team limit
rpm_limit=None, # No team limit
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request with any limits
data = GenerateKeyRequest(
tpm_limit=10000, # High limit should be allowed
rpm_limit=2000, # High limit should be allowed
)
# Should not raise any exception
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
@pytest.mark.asyncio
async def test_check_team_key_limits_no_key_limits():
"""
Test _check_team_key_limits when new key has no TPM/RPM limits.
Should not raise any exceptions since no limits are being allocated.
"""
# Create mock existing keys
existing_key = MagicMock()
existing_key.tpm_limit = 8000
existing_key.rpm_limit = 800
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-303",
team_alias="test-team",
tpm_limit=10000,
rpm_limit=1000,
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request with no limits
data = GenerateKeyRequest(
tpm_limit=None, # No limit being set
rpm_limit=None, # No limit being set
)
# Should not raise any exception
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
@pytest.mark.asyncio
async def test_check_team_key_limits_mixed_scenarios():
"""
Test _check_team_key_limits with mixed scenarios:
- Some existing keys have limits, others don't
- New key has only one type of limit
- Team has only one type of limit
"""
# Create mock existing keys with mixed limits
existing_key1 = MagicMock()
existing_key1.tpm_limit = 3000
existing_key1.rpm_limit = None # No RPM limit
existing_key2 = MagicMock()
existing_key2.tpm_limit = None # No TPM limit
existing_key2.rpm_limit = 400
existing_key3 = MagicMock()
existing_key3.tpm_limit = 2000
existing_key3.rpm_limit = 300
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key1, existing_key2, existing_key3]
)
# Create team table with only TPM limit
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-404",
team_alias="test-team",
tpm_limit=10000, # Allocated: 3000 + 0 + 2000 = 5000, New: 4000, Total: 9000 < 10000 ✓
rpm_limit=None, # No team RPM limit
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request with only TPM limit
data = GenerateKeyRequest(
tpm_limit=4000,
rpm_limit=None, # No RPM limit being set
)
# Should not raise any exception
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
@pytest.mark.asyncio
async def test_check_team_key_limits_exact_boundary():
"""
Test _check_team_key_limits when allocation exactly matches team limits.
Should allow the allocation (boundary case).
"""
# Create mock existing keys
existing_key = MagicMock()
existing_key.tpm_limit = 7000
existing_key.rpm_limit = 700
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[existing_key]
)
# Create team table with limits
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-505",
team_alias="test-team",
tpm_limit=10000, # Allocated: 7000, New: 3000, Total: 10000 = 10000 ✓
rpm_limit=1000, # Allocated: 700, New: 300, Total: 1000 = 1000 ✓
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
)
# Create request that exactly matches remaining capacity
data = GenerateKeyRequest(
tpm_limit=3000,
rpm_limit=300,
)
# Should not raise any exception (exact boundary should be allowed)
await _check_team_key_limits(
team_table=team_table,
data=data,
prisma_client=mock_prisma_client,
)
def test_check_team_key_model_specific_limits_no_limits():
"""
Test check_team_key_model_specific_limits when no model-specific limits are set.
Should return without raising any exceptions.
"""
# Create existing key with no model-specific limits
existing_key = LiteLLM_VerificationToken(
token="test-token-1",
user_id="test-user",
team_id="test-team-123",
metadata={},
)
keys = [existing_key]
# Create team table
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-123",
team_alias="test-team",
tpm_limit=10000,
rpm_limit=1000,
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
metadata={},
)
# Create request with no model-specific limits
data = GenerateKeyRequest(
model_rpm_limit=None,
model_tpm_limit=None,
)
# Should not raise any exception
check_team_key_model_specific_limits(
keys=keys,
team_table=team_table,
data=data,
)
def test_check_team_key_model_specific_limits_rpm_overallocation():
"""
Test check_team_key_model_specific_limits when model-specific RPM would cause overallocation.
Should raise HTTPException with appropriate error message.
"""
# Create existing keys with model-specific RPM limits
existing_key1 = LiteLLM_VerificationToken(
token="test-token-1",
user_id="test-user-1",
team_id="test-team-456",
metadata={
"model_rpm_limit": {
"gpt-4": 500,
"gpt-3.5-turbo": 300,
}
},
)
existing_key2 = LiteLLM_VerificationToken(
token="test-token-2",
user_id="test-user-2",
team_id="test-team-456",
metadata={
"model_rpm_limit": {
"gpt-4": 300,
}
},
)
keys = [existing_key1, existing_key2]
# Create team table with RPM limit
team_table = LiteLLM_TeamTableCachedObj(
team_id="test-team-456",
team_alias="test-team",
tpm_limit=10000,
rpm_limit=1000, # Total team RPM limit
max_budget=100.0,
spend=0.0,
models=[],
blocked=False,
members_with_roles=[],
metadata={},
)
# Create request that would exceed model-specific RPM limits
# Existing gpt-4: 500 + 300 = 800, New: 300, Total: 1100 > 1000 (team limit)
data = GenerateKeyRequest(
model_rpm_limit={
"gpt-4": 300, # This would cause overallocation
},
model_tpm_limit=None,
)
# Should raise HTTPException for model-specific RPM overallocation
with pytest.raises(HTTPException) as exc_info:
check_team_key_model_specific_limits(
keys=keys,
team_table=team_table,
data=data,
)
assert exc_info.value.status_code == 400
assert (
"Allocated RPM limit=800 + Key RPM limit=300 is greater than team RPM limit=1000"
in str(exc_info.value.detail)
)
@@ -0,0 +1,98 @@
import React from "react"
import { Form, Select, Tooltip } from "antd"
import { InfoCircleOutlined } from "@ant-design/icons"
const { Option } = Select
interface RateLimitTypeFormItemProps {
/** The type of rate limit - either 'tpm' or 'rpm' */
type: 'tpm' | 'rpm'
/** The form field name */
name: string
/** Whether to show detailed descriptions (default: true) */
showDetailedDescriptions?: boolean
/** Additional CSS classes */
className?: string
/** Initial value for the field */
initialValue?: string | null
/** Form instance for setting field values */
form?: any
/** Custom onChange handler */
onChange?: (value: string) => void
}
export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
type,
name,
showDetailedDescriptions = true,
className = "",
initialValue = null,
form,
onChange
}) => {
const limitTypeUpper = type.toUpperCase()
const limitTypeLower = type.toLowerCase()
const handleChange = (value: string) => {
if (form) {
form.setFieldValue(name, value)
}
if (onChange) {
onChange(value)
}
}
const tooltipTitle = `Select 'guaranteed_throughput' to prevent overallocating ${limitTypeUpper} limit when the key belongs to a Team with specific ${limitTypeUpper} limits.`
return (
<Form.Item
label={
<span>
{limitTypeUpper} Rate Limit Type{' '}
<Tooltip title={tooltipTitle}>
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name={name}
initialValue={initialValue}
className={className}
>
<Select
defaultValue={showDetailedDescriptions ? "default" : undefined}
placeholder="Select rate limit type"
style={{ width: "100%" }}
optionLabelProp={showDetailedDescriptions ? "label" : undefined}
onChange={handleChange}
>
{showDetailedDescriptions ? (
<>
<Option value="best_effort_throughput" label="Default">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>Default</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Best effort throughput - no error if we&apos;re overallocating {limitTypeLower} (Team/Key Limits checked at runtime).
</div>
</div>
</Option>
<Option value="guaranteed_throughput" label="Guaranteed throughput">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>Guaranteed throughput</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Guaranteed throughput - raise an error if we&apos;re overallocating {limitTypeLower} (also checks model-specific limits)
</div>
</div>
</Option>
</>
) : (
<>
<Option value="best_effort_throughput">Best effort throughput</Option>
<Option value="guaranteed_throughput">Guaranteed throughput</Option>
</>
)}
</Select>
</Form.Item>
)
}
export default RateLimitTypeFormItem
@@ -35,6 +35,7 @@ import MCPServerSelector from "../mcp_server_management/MCPServerSelector"
import ModelAliasManager from "../common_components/ModelAliasManager"
import NotificationsManager from "../molecules/notifications_manager"
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"
const { Option } = Select;
@@ -810,6 +811,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({
>
<NumericalInput step={1} width={400} />
</Form.Item>
<RateLimitTypeFormItem
type="tpm"
name="tpm_limit_type"
className="mt-4"
initialValue={null}
form={form}
showDetailedDescriptions={true}
/>
<Form.Item
className="mt-4"
label={
@@ -841,6 +850,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({
>
<NumericalInput step={1} width={400} />
</Form.Item>
<RateLimitTypeFormItem
type="rpm"
name="rpm_limit_type"
className="mt-4"
initialValue={null}
form={form}
showDetailedDescriptions={true}
/>
<Form.Item
label={
<span>
@@ -13,6 +13,7 @@ import { fetchMCPAccessGroups } from "../networking"
import { mapInternalToDisplayNames, mapDisplayToInternalNames } from "../callback_info_helpers"
import GuardrailSelector from "@/components/guardrails/GuardrailSelector"
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"
interface KeyEditViewProps {
keyData: KeyResponse
@@ -222,10 +223,22 @@ export function KeyEditView({
<NumericalInput min={0} />
</Form.Item>
<RateLimitTypeFormItem
type="tpm"
name="tpm_limit_type"
showDetailedDescriptions={false}
/>
<Form.Item label="RPM Limit" name="rpm_limit">
<NumericalInput min={0} />
</Form.Item>
<RateLimitTypeFormItem
type="rpm"
name="rpm_limit_type"
showDetailedDescriptions={false}
/>
<Form.Item label="Max Parallel Requests" name="max_parallel_requests">
<NumericalInput min={0} />
</Form.Item>