mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 08:18:09 +00:00
* feat(ui): add budget duration to edit team member form Editing a team member created a member budget with no duration, so the budget never reset. This threads a budget reset period through the edit flow end to end and reuses the shared duration dropdown so the options stay in sync with the rest of the UI. Resolves LIT-2651 * fix(proxy): validate member budget_duration and persist clears Reject budget_duration values that can't be parsed, are non-positive, or overflow date math before any write, so a bad value can't be persisted and later crash the budget reset job. Clearing the budget duration in the edit-member form now sends null and clears the column end to end, so the dropdown's clear control reflects a real change instead of being a no-op * chore(ui): regenerate schema.d.ts for member budget_duration Adds budget_duration to TeamMemberUpdateRequest/Response in the generated dashboard types so the Check UI API Types Sync gate passes
181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
import types
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from starlette.requests import Request
|
|
|
|
import litellm.proxy.proxy_server as proxy_server
|
|
import litellm.proxy.management_endpoints.team_endpoints as team_endpoints
|
|
from litellm.proxy._types import (
|
|
LiteLLM_TeamTable,
|
|
LitellmUserRoles,
|
|
Member,
|
|
TeamMemberUpdateRequest,
|
|
UserAPIKeyAuth,
|
|
)
|
|
from litellm.proxy.management_endpoints.team_endpoints import team_member_update
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ateam_member_update_admin_requires_premium(monkeypatch):
|
|
# Arrange: patch prisma_client and premium_user
|
|
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
|
|
# Create a request body that tries to set role=admin
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234",
|
|
user_id="user-1",
|
|
user_email=None,
|
|
role="admin",
|
|
max_budget_in_team=None,
|
|
)
|
|
scope = {"type": "http", "method": "POST", "path": "/team/member_update"}
|
|
request = Request(scope)
|
|
|
|
# We don't need a full auth object since premium check happens before auth is used
|
|
auth = object()
|
|
|
|
# Act & Assert: expect HTTPException 400 with the exact premium feature message
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
expected_msg = (
|
|
"Assigning team admins is a premium feature. You must be a LiteLLM Enterprise user to use this feature. "
|
|
"If you have a license please set `LITELLM_LICENSE` in your env. Get a 7 day trial key here: https://www.litellm.ai/#trial. "
|
|
"Pricing: https://www.litellm.ai/#pricing"
|
|
)
|
|
assert exc_info.value.detail == expected_msg
|
|
|
|
|
|
@pytest.fixture
|
|
def happy_path_upsert(monkeypatch):
|
|
"""Stub out the DB and the budget upsert so a team_member_update call reaches
|
|
_upsert_budget_and_membership, and hand back that mock to inspect the patch."""
|
|
team_row = LiteLLM_TeamTable(
|
|
team_id="team-1234",
|
|
members_with_roles=[Member(user_id="user-1", role="user")],
|
|
metadata={},
|
|
)
|
|
|
|
prisma_client = MagicMock()
|
|
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
|
prisma_client.db.litellm_teamtable.update = AsyncMock()
|
|
|
|
class _FakeTx:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
prisma_client.db.tx = MagicMock(return_value=_FakeTx())
|
|
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
monkeypatch.setattr(
|
|
team_endpoints,
|
|
"team_info",
|
|
AsyncMock(
|
|
return_value={
|
|
"team_info": team_row,
|
|
"team_memberships": [
|
|
types.SimpleNamespace(user_id="user-1", budget_id="bud-1")
|
|
],
|
|
}
|
|
),
|
|
)
|
|
upsert_mock = AsyncMock()
|
|
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
|
|
return upsert_mock
|
|
|
|
|
|
def _member_update_request(**overrides):
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234", user_id="user-1", role="user", **overrides
|
|
)
|
|
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
|
|
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
|
return data, request, auth
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_sends_provided_fields_as_patch(happy_path_upsert):
|
|
"""Fields the request sets must reach _upsert_budget_and_membership as a
|
|
budget patch, otherwise the member budget is never written/reset."""
|
|
data, request, auth = _member_update_request(
|
|
max_budget_in_team=10.0, budget_duration="30d"
|
|
)
|
|
|
|
response = await team_member_update(data, request, auth)
|
|
|
|
happy_path_upsert.assert_awaited_once()
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
|
|
"max_budget": 10.0,
|
|
"budget_duration": "30d",
|
|
}
|
|
assert response.budget_duration == "30d"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_explicit_null_clears_field(happy_path_upsert):
|
|
"""An explicitly-null field must be forwarded as None so the column is
|
|
cleared, rather than silently dropped."""
|
|
data, request, auth = _member_update_request(budget_duration=None)
|
|
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
|
|
"budget_duration": None
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_omits_unset_fields_from_patch(happy_path_upsert):
|
|
"""A request that touches no budget fields must produce an empty patch so the
|
|
member's existing budget is left untouched."""
|
|
data, request, auth = _member_update_request()
|
|
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_duration",
|
|
[
|
|
"not-a-duration", # unparseable garbage
|
|
"10x", # unsupported unit
|
|
"0d", # zero-length window
|
|
"999999999999999999999999d", # overflows datetime math
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_rejects_invalid_budget_duration(
|
|
monkeypatch, bad_duration
|
|
):
|
|
"""An invalid budget_duration must be rejected with a 400 before any DB
|
|
write, so it can never be persisted and later break the budget reset job."""
|
|
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
upsert_mock = AsyncMock()
|
|
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
|
|
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234",
|
|
user_id="user-1",
|
|
role="user",
|
|
budget_duration=bad_duration,
|
|
)
|
|
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
|
|
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "budget_duration" in str(exc_info.value.detail)
|
|
upsert_mock.assert_not_called()
|