mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-06 07:08:05 +00:00
Merge pull request #23437 from BerriAI/litellm_user-info-endpoint-v2-24cc
[Feature] User Info V2 Endpoint
This commit is contained in:
@@ -484,6 +484,7 @@ class LiteLLMRoutes(enum.Enum):
|
||||
"/organization/list",
|
||||
"/team/available",
|
||||
"/user/info",
|
||||
"/v2/user/info",
|
||||
"/model/info",
|
||||
"/v1/model/info",
|
||||
"/v2/model/info",
|
||||
@@ -2552,6 +2553,30 @@ class UserInfoResponse(LiteLLMPydanticObjectBase):
|
||||
teams: List
|
||||
|
||||
|
||||
class UserInfoV2Response(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
Response model for GET /v2/user/info
|
||||
|
||||
Returns ONLY the user object - no keys, no teams objects.
|
||||
This is a lightweight alternative to UserInfoResponse.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
user_email: Optional[str] = None
|
||||
user_alias: Optional[str] = None
|
||||
user_role: Optional[str] = None
|
||||
spend: float = 0.0
|
||||
max_budget: Optional[float] = None
|
||||
models: List[str] = []
|
||||
budget_duration: Optional[str] = None
|
||||
budget_reset_at: Optional[datetime] = None
|
||||
metadata: Optional[dict] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
sso_user_id: Optional[str] = None
|
||||
teams: List[str] = [] # Just team IDs, not full team objects
|
||||
|
||||
|
||||
class LiteLLM_Config(LiteLLMPydanticObjectBase):
|
||||
param_name: str
|
||||
param_value: Dict
|
||||
|
||||
@@ -183,6 +183,9 @@ class RouteChecks:
|
||||
user_id, valid_token.user_id
|
||||
),
|
||||
)
|
||||
elif route == "/v2/user/info":
|
||||
# handled by the endpoint itself (full RBAC in handler)
|
||||
pass
|
||||
elif route == "/model/info":
|
||||
# /model/info just shows models user has access to
|
||||
pass
|
||||
|
||||
@@ -35,6 +35,18 @@ class UsersManagementClient:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_user_v2(self, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get user info v2 - lightweight, returns only user object (GET /v2/user/info)"""
|
||||
url = f"{self.base_url}/v2/user/info"
|
||||
params = {"user_id": user_id} if user_id else {}
|
||||
response = requests.get(url, headers=self._get_headers(), params=params)
|
||||
if response.status_code == 401:
|
||||
raise UnauthorizedError(response.text)
|
||||
if response.status_code == 404:
|
||||
raise NotFoundError(response.text)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_user(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new user (POST /user/new)"""
|
||||
url = f"{self.base_url}/user/new"
|
||||
|
||||
@@ -31,7 +31,10 @@ from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
get_daily_activity_aggregated,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
|
||||
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_team_admin,
|
||||
_user_has_admin_view,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
generate_key_helper_fn,
|
||||
prepare_metadata_fields,
|
||||
@@ -715,6 +718,166 @@ async def user_info(
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
async def _check_user_info_v2_access(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
target_user_id: str,
|
||||
) -> Optional["LiteLLM_UserTable"]:
|
||||
"""
|
||||
Check if the caller is allowed to access the target user's info.
|
||||
|
||||
Returns the target user's DB row if access is allowed, None otherwise.
|
||||
Returning the row avoids a redundant DB fetch in the caller.
|
||||
|
||||
Access rules:
|
||||
1. Proxy admins / proxy admin viewers can access any user
|
||||
2. User can access their own info
|
||||
3. Team admins can access info of users in their teams
|
||||
|
||||
Raises on unexpected DB errors so they surface as 500s, not silent 404s.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
return None
|
||||
|
||||
# Helper: fetch the target user row (reused across branches)
|
||||
async def _fetch_target_user():
|
||||
return await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": target_user_id}
|
||||
)
|
||||
|
||||
# Rule 1: Proxy admins — fetch and return the target row directly
|
||||
if _user_has_admin_view(user_api_key_dict):
|
||||
return await _fetch_target_user()
|
||||
|
||||
# Rule 2: Self-lookup
|
||||
if user_api_key_dict.user_id == target_user_id:
|
||||
return await _fetch_target_user()
|
||||
|
||||
# Rule 3: Team admins can look up users in their teams
|
||||
if user_api_key_dict.user_id is not None:
|
||||
# Get caller's teams
|
||||
caller_user = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id}
|
||||
)
|
||||
if caller_user is not None and caller_user.teams:
|
||||
# Fetch the target user ONCE, before the loop
|
||||
target_user = await _fetch_target_user()
|
||||
if target_user is None:
|
||||
return None
|
||||
|
||||
# Get all teams the caller belongs to
|
||||
teams = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": caller_user.teams}}
|
||||
)
|
||||
for team in teams:
|
||||
team_obj = LiteLLM_TeamTable(**team.model_dump())
|
||||
if _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_obj
|
||||
):
|
||||
# Check if target user is in this team
|
||||
if team.team_id in (target_user.teams or []):
|
||||
return target_user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v2/user/info",
|
||||
tags=["Internal User management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=UserInfoV2Response,
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def user_info_v2(
|
||||
request: Request,
|
||||
user_id: Optional[str] = fastapi.Query(
|
||||
default=None, description="User ID in the request parameters"
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Lightweight endpoint to get user info. Returns only the user object — no keys, no teams objects.
|
||||
|
||||
This is the v2 replacement for /user/info, designed to avoid the "god endpoint" problem
|
||||
where the old endpoint loaded all keys and teams into memory.
|
||||
|
||||
Access control:
|
||||
- Proxy admins can query any user
|
||||
- Team admins can query users within their teams
|
||||
- Internal users can only query themselves (omit user_id or pass own)
|
||||
- Returns 404 for non-existent users or unauthorized access
|
||||
|
||||
Example request:
|
||||
```
|
||||
curl -X GET 'http://localhost:4000/v2/user/info?user_id=user123' \\
|
||||
--header 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
try:
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
|
||||
# Handle URL encoding for + characters
|
||||
if user_id is not None and " " in user_id:
|
||||
user_id = get_user_id_from_request(request=request)
|
||||
|
||||
# Default to self-lookup if no user_id provided
|
||||
if user_id is None:
|
||||
user_id = user_api_key_dict.user_id
|
||||
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="user_id is required. Either pass it as a query parameter or authenticate with a user-bound key.",
|
||||
)
|
||||
|
||||
# Check access — returns the user row if allowed, None otherwise.
|
||||
# This avoids a redundant DB fetch since the access check already
|
||||
# loads the target user for team-admin verification.
|
||||
user_row = await _check_user_info_v2_access(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
if user_row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"User not found: {user_id}",
|
||||
)
|
||||
|
||||
user_data = user_row.model_dump()
|
||||
|
||||
return UserInfoV2Response(
|
||||
user_id=user_data.get("user_id", user_id),
|
||||
user_email=user_data.get("user_email"),
|
||||
user_alias=user_data.get("user_alias"),
|
||||
user_role=user_data.get("user_role"),
|
||||
spend=user_data.get("spend", 0.0),
|
||||
max_budget=user_data.get("max_budget"),
|
||||
models=user_data.get("models") or [],
|
||||
budget_duration=user_data.get("budget_duration"),
|
||||
budget_reset_at=user_data.get("budget_reset_at"),
|
||||
metadata=user_data.get("metadata"),
|
||||
created_at=user_data.get("created_at"),
|
||||
updated_at=user_data.get("updated_at"),
|
||||
sso_user_id=user_data.get("sso_user_id"),
|
||||
teams=user_data.get("teams") or [],
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"litellm.proxy.proxy_server.user_info_v2(): Exception occured - {}".format(
|
||||
str(e)
|
||||
)
|
||||
)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
async def _get_user_info_for_proxy_admin(user_api_key_dict: UserAPIKeyAuth):
|
||||
"""
|
||||
Admin UI Endpoint - Returns All Teams and Keys when Proxy Admin is querying
|
||||
|
||||
@@ -117,3 +117,30 @@ def test_team_info_route_access():
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_v2_user_info_route_in_info_routes():
|
||||
"""Test that /v2/user/info is in the info_routes list"""
|
||||
assert "/v2/user/info" in LiteLLMRoutes.info_routes.value
|
||||
|
||||
|
||||
def test_v2_user_info_route_access():
|
||||
"""Test access control for /v2/user/info route - handled by endpoint itself"""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="test@example.com",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user")
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {"user_id": "other_user"}
|
||||
|
||||
# Should not raise exception as /v2/user/info handles its own RBAC logic in the handler
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
route="/v2/user/info",
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
@@ -1728,4 +1728,563 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
|
||||
# Verify each condition uses {"in": ["admin-creator"]}
|
||||
for condition in or_conditions:
|
||||
field = list(condition.keys())[0]
|
||||
assert condition[field] == {"in": ["admin-creator"]}
|
||||
assert condition[field] == {"in": ["admin-creator"]}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# /v2/user/info endpoint tests
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_proxy_admin_can_query_any_user(mocker):
|
||||
"""
|
||||
Test that proxy admin can query any user via /v2/user/info.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
mock_user_row = mocker.MagicMock()
|
||||
mock_user_row.model_dump.return_value = {
|
||||
"user_id": "target-user-123",
|
||||
"user_email": "target@example.com",
|
||||
"user_alias": "Target User",
|
||||
"user_role": "internal_user",
|
||||
"spend": 42.5,
|
||||
"max_budget": 100.0,
|
||||
"models": ["gpt-4"],
|
||||
"budget_duration": "30d",
|
||||
"budget_reset_at": None,
|
||||
"metadata": {"team": "engineering"},
|
||||
"created_at": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||
"sso_user_id": "sso-abc",
|
||||
"teams": ["team-1", "team-2"],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
if kwargs.get("where", {}).get("user_id") == "target-user-123":
|
||||
return mock_user_row
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
admin_key = UserAPIKeyAuth(
|
||||
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="target-user-123",
|
||||
user_api_key_dict=admin_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
assert response.user_id == "target-user-123"
|
||||
assert response.user_email == "target@example.com"
|
||||
assert response.user_alias == "Target User"
|
||||
assert response.user_role == "internal_user"
|
||||
assert response.spend == 42.5
|
||||
assert response.max_budget == 100.0
|
||||
assert response.models == ["gpt-4"]
|
||||
assert response.teams == ["team-1", "team-2"]
|
||||
assert response.sso_user_id == "sso-abc"
|
||||
assert response.metadata == {"team": "engineering"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_internal_user_can_query_self(mocker):
|
||||
"""
|
||||
Test that an internal user can query their own info.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
mock_user_row = mocker.MagicMock()
|
||||
mock_user_row.model_dump.return_value = {
|
||||
"user_id": "self-user",
|
||||
"user_email": "self@example.com",
|
||||
"user_alias": None,
|
||||
"user_role": "internal_user",
|
||||
"spend": 10.0,
|
||||
"max_budget": None,
|
||||
"models": [],
|
||||
"budget_duration": None,
|
||||
"budget_reset_at": None,
|
||||
"metadata": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"sso_user_id": None,
|
||||
"teams": [],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
if kwargs.get("where", {}).get("user_id") == "self-user":
|
||||
return mock_user_row
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
user_key = UserAPIKeyAuth(
|
||||
user_id="self-user", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="self-user",
|
||||
user_api_key_dict=user_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
assert response.user_id == "self-user"
|
||||
assert response.user_email == "self@example.com"
|
||||
assert response.spend == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_internal_user_cannot_query_other(mocker):
|
||||
"""
|
||||
Test that an internal user cannot query another user - returns 404.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
# Caller user has no teams (so no team admin access)
|
||||
mock_caller_row = mocker.MagicMock()
|
||||
mock_caller_row.teams = []
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
user_id = kwargs.get("where", {}).get("user_id")
|
||||
if user_id == "caller-user":
|
||||
return mock_caller_row
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
user_key = UserAPIKeyAuth(
|
||||
user_id="caller-user", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="other-user-456",
|
||||
user_api_key_dict=user_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "404"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_no_user_id_defaults_to_self(mocker):
|
||||
"""
|
||||
Test that omitting user_id defaults to the caller's own user info.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
mock_user_row = mocker.MagicMock()
|
||||
mock_user_row.model_dump.return_value = {
|
||||
"user_id": "my-user-id",
|
||||
"user_email": "me@example.com",
|
||||
"user_alias": None,
|
||||
"user_role": "internal_user",
|
||||
"spend": 0.0,
|
||||
"max_budget": None,
|
||||
"models": [],
|
||||
"budget_duration": None,
|
||||
"budget_reset_at": None,
|
||||
"metadata": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"sso_user_id": None,
|
||||
"teams": [],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
if kwargs.get("where", {}).get("user_id") == "my-user-id":
|
||||
return mock_user_row
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
user_key = UserAPIKeyAuth(
|
||||
user_id="my-user-id", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
# Call without user_id
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id=None,
|
||||
user_api_key_dict=user_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
assert response.user_id == "my-user-id"
|
||||
assert response.user_email == "me@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_nonexistent_user_returns_404(mocker):
|
||||
"""
|
||||
Test that querying a nonexistent user returns 404.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
admin_key = UserAPIKeyAuth(
|
||||
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="nonexistent-user-id",
|
||||
user_api_key_dict=admin_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "404"
|
||||
assert "nonexistent-user-id" in str(exc_info.value.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_response_shape(mocker):
|
||||
"""
|
||||
Test that the response shape contains expected fields and
|
||||
does NOT contain keys or teams objects (only team IDs).
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
mock_user_row = mocker.MagicMock()
|
||||
mock_user_row.model_dump.return_value = {
|
||||
"user_id": "shape-test-user",
|
||||
"user_email": "shape@example.com",
|
||||
"user_alias": "Shape Test",
|
||||
"user_role": "internal_user",
|
||||
"spend": 5.0,
|
||||
"max_budget": 50.0,
|
||||
"models": ["gpt-3.5-turbo"],
|
||||
"budget_duration": "7d",
|
||||
"budget_reset_at": datetime(2024, 7, 1, tzinfo=timezone.utc),
|
||||
"metadata": {"env": "test"},
|
||||
"created_at": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||
"sso_user_id": None,
|
||||
"teams": ["team-a", "team-b"],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
return mock_user_row
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
admin_key = UserAPIKeyAuth(
|
||||
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="shape-test-user",
|
||||
user_api_key_dict=admin_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
|
||||
# Verify all expected fields are present
|
||||
response_dict = response.model_dump()
|
||||
expected_fields = {
|
||||
"user_id", "user_email", "user_alias", "user_role", "spend",
|
||||
"max_budget", "models", "budget_duration", "budget_reset_at",
|
||||
"metadata", "created_at", "updated_at", "sso_user_id", "teams",
|
||||
}
|
||||
assert set(response_dict.keys()) == expected_fields
|
||||
|
||||
# Verify teams is a list of strings (team IDs), not team objects
|
||||
assert isinstance(response.teams, list)
|
||||
assert all(isinstance(t, str) for t in response.teams)
|
||||
assert response.teams == ["team-a", "team-b"]
|
||||
|
||||
# Verify models is a list of strings
|
||||
assert isinstance(response.models, list)
|
||||
assert response.models == ["gpt-3.5-turbo"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_team_admin_can_query_team_member(mocker):
|
||||
"""
|
||||
Test that a team admin can query info of a user in their team.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
# Caller (team admin)
|
||||
mock_caller = mocker.MagicMock()
|
||||
mock_caller.teams = ["shared-team-id"]
|
||||
|
||||
# Target user (team member)
|
||||
mock_target = mocker.MagicMock()
|
||||
mock_target.teams = ["shared-team-id"]
|
||||
mock_target.model_dump.return_value = {
|
||||
"user_id": "target-member",
|
||||
"user_email": "member@example.com",
|
||||
"user_alias": None,
|
||||
"user_role": "internal_user",
|
||||
"spend": 0.0,
|
||||
"max_budget": None,
|
||||
"models": [],
|
||||
"budget_duration": None,
|
||||
"budget_reset_at": None,
|
||||
"metadata": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"sso_user_id": None,
|
||||
"teams": ["shared-team-id"],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
uid = kwargs.get("where", {}).get("user_id")
|
||||
if uid == "team-admin-user":
|
||||
return mock_caller
|
||||
elif uid == "target-member":
|
||||
return mock_target
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
# Mock team with caller as admin
|
||||
mock_team = mocker.MagicMock()
|
||||
mock_team.team_id = "shared-team-id"
|
||||
mock_team.model_dump.return_value = {
|
||||
"team_id": "shared-team-id",
|
||||
"team_alias": "Shared Team",
|
||||
"members_with_roles": [
|
||||
{"user_id": "team-admin-user", "role": "admin"},
|
||||
{"user_id": "target-member", "role": "user"},
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_find_many_teams(*args, **kwargs):
|
||||
return [mock_team]
|
||||
|
||||
mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
|
||||
side_effect=mock_find_many_teams
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
team_admin_key = UserAPIKeyAuth(
|
||||
user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="target-member",
|
||||
user_api_key_dict=team_admin_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
assert response.user_id == "target-member"
|
||||
assert response.user_email == "member@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker):
|
||||
"""
|
||||
Test that a team admin cannot query a user NOT in their team - returns 404.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
# Caller (team admin of team-A)
|
||||
mock_caller = mocker.MagicMock()
|
||||
mock_caller.teams = ["team-A"]
|
||||
|
||||
# Target user (in team-B only)
|
||||
mock_target = mocker.MagicMock()
|
||||
mock_target.teams = ["team-B"]
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
uid = kwargs.get("where", {}).get("user_id")
|
||||
if uid == "team-admin-user":
|
||||
return mock_caller
|
||||
elif uid == "non-member-user":
|
||||
return mock_target
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
# Mock team where caller is admin
|
||||
mock_team = mocker.MagicMock()
|
||||
mock_team.team_id = "team-A"
|
||||
mock_team.model_dump.return_value = {
|
||||
"team_id": "team-A",
|
||||
"team_alias": "Team A",
|
||||
"members_with_roles": [
|
||||
{"user_id": "team-admin-user", "role": "admin"},
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_find_many_teams(*args, **kwargs):
|
||||
return [mock_team]
|
||||
|
||||
mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
|
||||
side_effect=mock_find_many_teams
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
|
||||
team_admin_key = UserAPIKeyAuth(
|
||||
user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id="non-member-user",
|
||||
user_api_key_dict=team_admin_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "404"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_v2_url_encoding_plus_character(mocker):
|
||||
"""
|
||||
Test that /v2/user/info properly handles email addresses with + characters.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import UserInfoV2Response
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
|
||||
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
|
||||
expected_user_id = "machine-user+admin@example.com"
|
||||
|
||||
mock_user_row = mocker.MagicMock()
|
||||
mock_user_row.model_dump.return_value = {
|
||||
"user_id": expected_user_id,
|
||||
"user_email": expected_user_id,
|
||||
"user_alias": None,
|
||||
"user_role": "internal_user",
|
||||
"spend": 0.0,
|
||||
"max_budget": None,
|
||||
"models": [],
|
||||
"budget_duration": None,
|
||||
"budget_reset_at": None,
|
||||
"metadata": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"sso_user_id": None,
|
||||
"teams": [],
|
||||
}
|
||||
|
||||
async def mock_find_unique(*args, **kwargs):
|
||||
uid = kwargs.get("where", {}).get("user_id")
|
||||
if uid == expected_user_id:
|
||||
return mock_user_row
|
||||
return None
|
||||
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
|
||||
side_effect=mock_find_unique
|
||||
)
|
||||
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
mock_request = mocker.MagicMock(spec=Request)
|
||||
mock_request.url.query = f"user_id={expected_user_id}"
|
||||
|
||||
admin_key = UserAPIKeyAuth(
|
||||
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
# Simulate FastAPI converting + to space
|
||||
decoded_user_id = "machine-user admin@example.com"
|
||||
|
||||
response = await user_info_v2(
|
||||
request=mock_request,
|
||||
user_id=decoded_user_id,
|
||||
user_api_key_dict=admin_key,
|
||||
)
|
||||
|
||||
assert isinstance(response, UserInfoV2Response)
|
||||
assert response.user_id == expected_user_id
|
||||
@@ -3,12 +3,12 @@ import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useCurrentUser } from "./useCurrentUser";
|
||||
import { userInfoCall } from "@/components/networking";
|
||||
import type { UserInfo } from "@/components/view_users/types";
|
||||
import { userGetInfoV2 } from "@/components/networking";
|
||||
import type { UserInfoV2Response } from "@/components/networking";
|
||||
|
||||
// Mock the networking function
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userInfoCall: vi.fn(),
|
||||
userGetInfoV2: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the queryKeysFactory - we'll mock the specific return value
|
||||
@@ -28,21 +28,22 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
// Mock data - response from userInfoCall should have user_info property
|
||||
const mockUserInfoResponse = {
|
||||
user_info: {
|
||||
user_id: "test-user-id",
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test User",
|
||||
user_role: "Admin",
|
||||
spend: 150.75,
|
||||
max_budget: 1000.0,
|
||||
key_count: 5,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
sso_user_id: null,
|
||||
budget_duration: "monthly",
|
||||
} as UserInfo,
|
||||
// Mock data - response from userGetInfoV2 is the user object directly
|
||||
const mockUserInfoV2Response: UserInfoV2Response = {
|
||||
user_id: "test-user-id",
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test User",
|
||||
user_role: "internal_user",
|
||||
spend: 150.75,
|
||||
max_budget: 1000.0,
|
||||
models: ["gpt-4"],
|
||||
budget_duration: "monthly",
|
||||
budget_reset_at: null,
|
||||
metadata: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
sso_user_id: null,
|
||||
teams: ["team-1"],
|
||||
};
|
||||
|
||||
describe("useCurrentUser", () => {
|
||||
@@ -77,8 +78,8 @@ describe("useCurrentUser", () => {
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("should return user info data when query is successful", async () => {
|
||||
// Mock successful API call
|
||||
(userInfoCall as any).mockResolvedValue(mockUserInfoResponse);
|
||||
// Mock successful API call - v2 returns user object directly
|
||||
(userGetInfoV2 as any).mockResolvedValue(mockUserInfoV2Response);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser(), { wrapper });
|
||||
|
||||
@@ -92,18 +93,19 @@ describe("useCurrentUser", () => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockUserInfoResponse.user_info);
|
||||
expect(result.current.data).toEqual(mockUserInfoV2Response);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(userInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", false, null, null);
|
||||
expect(userInfoCall).toHaveBeenCalledTimes(1);
|
||||
// v2 call only needs accessToken (no userId for self-lookup)
|
||||
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token");
|
||||
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle error when userInfoCall fails", async () => {
|
||||
it("should handle error when userGetInfoV2 fails", async () => {
|
||||
const errorMessage = "Failed to fetch user info";
|
||||
const testError = new Error(errorMessage);
|
||||
|
||||
// Mock failed API call
|
||||
(userInfoCall as any).mockRejectedValue(testError);
|
||||
(userGetInfoV2 as any).mockRejectedValue(testError);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser(), { wrapper });
|
||||
|
||||
@@ -118,8 +120,8 @@ describe("useCurrentUser", () => {
|
||||
|
||||
expect(result.current.error).toEqual(testError);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(userInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", false, null, null);
|
||||
expect(userInfoCall).toHaveBeenCalledTimes(1);
|
||||
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token");
|
||||
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not execute query when accessToken is missing", async () => {
|
||||
@@ -143,7 +145,7 @@ describe("useCurrentUser", () => {
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
|
||||
// API should not be called
|
||||
expect(userInfoCall).not.toHaveBeenCalled();
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not execute query when userId is missing", async () => {
|
||||
@@ -167,31 +169,7 @@ describe("useCurrentUser", () => {
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
|
||||
// API should not be called
|
||||
expect(userInfoCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not execute query when userRole is missing", async () => {
|
||||
// Mock missing userRole
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "test-access-token",
|
||||
userId: "test-user-id",
|
||||
userRole: null,
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser(), { wrapper });
|
||||
|
||||
// Query should not execute
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
|
||||
// API should not be called
|
||||
expect(userInfoCall).not.toHaveBeenCalled();
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not execute query when all auth values are missing", async () => {
|
||||
@@ -215,12 +193,12 @@ describe("useCurrentUser", () => {
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
|
||||
// API should not be called
|
||||
expect(userInfoCall).not.toHaveBeenCalled();
|
||||
expect(userGetInfoV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should execute query when all auth values are present", async () => {
|
||||
// Mock successful API call
|
||||
(userInfoCall as any).mockResolvedValue(mockUserInfoResponse);
|
||||
(userGetInfoV2 as any).mockResolvedValue(mockUserInfoV2Response);
|
||||
|
||||
// Ensure all auth values are present (already set in beforeEach)
|
||||
const { result } = renderHook(() => useCurrentUser(), { wrapper });
|
||||
@@ -230,15 +208,15 @@ describe("useCurrentUser", () => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(userInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", false, null, null);
|
||||
expect(userInfoCall).toHaveBeenCalledTimes(1);
|
||||
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token");
|
||||
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle network timeout error", async () => {
|
||||
const timeoutError = new Error("Network timeout");
|
||||
|
||||
// Mock network timeout
|
||||
(userInfoCall as any).mockRejectedValue(timeoutError);
|
||||
(userGetInfoV2 as any).mockRejectedValue(timeoutError);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser(), { wrapper });
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { UserInfo, userInfoCall } from "@/components/networking";
|
||||
import { UserInfoV2Response, userGetInfoV2 } from "@/components/networking";
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const userKeys = createQueryKeys("users");
|
||||
|
||||
export const useCurrentUser = (): UseQueryResult<UserInfo> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
return useQuery<UserInfo>({
|
||||
export const useCurrentUser = (): UseQueryResult<UserInfoV2Response> => {
|
||||
const { accessToken, userId } = useAuthorized();
|
||||
return useQuery<UserInfoV2Response>({
|
||||
queryKey: userKeys.detail(userId!),
|
||||
queryFn: async () => {
|
||||
const data = await userInfoCall(accessToken!, userId!, userRole!, false, null, null);
|
||||
console.log(`userInfo: ${JSON.stringify(data)}`);
|
||||
return data.user_info;
|
||||
return await userGetInfoV2(accessToken!);
|
||||
},
|
||||
enabled: Boolean(accessToken && userId && userRole),
|
||||
enabled: Boolean(accessToken && userId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1199,6 +1199,65 @@ export const userListCall = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Response type for /v2/user/info — lightweight endpoint that returns only the user object.
|
||||
*/
|
||||
export interface UserInfoV2Response {
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_alias: string | null;
|
||||
user_role: string | null;
|
||||
spend: number;
|
||||
max_budget: number | null;
|
||||
models: string[];
|
||||
budget_duration: string | null;
|
||||
budget_reset_at: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
sso_user_id: string | null;
|
||||
teams: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight user info fetch from /v2/user/info.
|
||||
* Returns only the user object — no keys, no teams objects.
|
||||
*
|
||||
* @param accessToken - Bearer token for auth
|
||||
* @param userId - Optional user ID to look up. If omitted, returns the caller's own info.
|
||||
*/
|
||||
export const userGetInfoV2 = async (
|
||||
accessToken: string,
|
||||
userId?: string,
|
||||
): Promise<UserInfoV2Response> => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/v2/user/info` : `/v2/user/info`;
|
||||
if (userId) {
|
||||
url += `?user_id=${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user info v2:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userInfoCall = async (
|
||||
accessToken: string,
|
||||
userID: string | null,
|
||||
|
||||
@@ -21,7 +21,14 @@ vi.mock("./networking", async (importOriginal) => {
|
||||
getProxyUISettings: vi.fn().mockResolvedValue({}),
|
||||
keyInfoCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
userInfoCall: vi.fn().mockResolvedValue({ user_info: {}, keys: [], teams: [] }),
|
||||
userGetInfoV2: vi.fn().mockResolvedValue({
|
||||
user_id: "user-1",
|
||||
user_email: "test@example.com",
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
models: [],
|
||||
teams: [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
keyInfoCall,
|
||||
modelAvailableCall,
|
||||
Organization,
|
||||
userInfoCall,
|
||||
userGetInfoV2,
|
||||
} from "./networking";
|
||||
import CreateKey, { CreateKeyPrefillData } from "./organisms/create_key_button";
|
||||
import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable";
|
||||
@@ -165,7 +165,7 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userID && accessToken && userRole && !keys && !userSpendData) {
|
||||
if (userID && accessToken && userRole && !userSpendData) {
|
||||
const cachedUserModels = sessionStorage.getItem("userModels" + userID);
|
||||
if (cachedUserModels) {
|
||||
setUserModels(JSON.parse(cachedUserModels));
|
||||
@@ -176,26 +176,11 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
const proxy_settings: ProxySettings = await getProxyUISettings(accessToken);
|
||||
setProxySettings(proxy_settings);
|
||||
|
||||
const response = await userInfoCall(accessToken, userID, userRole, false, null, null);
|
||||
const response = await userGetInfoV2(accessToken, userID);
|
||||
|
||||
setUserSpendData(response["user_info"]);
|
||||
console.log(`userSpendData: ${JSON.stringify(userSpendData)}`);
|
||||
setUserSpendData(response);
|
||||
|
||||
// set keys for admin and users
|
||||
if (!response?.teams[0].keys) {
|
||||
setKeys(response["keys"]);
|
||||
} else {
|
||||
setKeys(
|
||||
response["keys"].concat(
|
||||
response.teams
|
||||
.filter((team: any) => userRole === "Admin" || team.user_id === userID)
|
||||
.flatMap((team: any) => team.keys),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sessionStorage.setItem("userData" + userID, JSON.stringify(response["keys"]));
|
||||
sessionStorage.setItem("userSpendData" + userID, JSON.stringify(response["user_info"]));
|
||||
sessionStorage.setItem("userSpendData" + userID, JSON.stringify(response));
|
||||
|
||||
const model_available = await modelAvailableCall(accessToken, userID, userRole);
|
||||
// loop through model_info["data"] and create an array of element.model_name
|
||||
@@ -218,7 +203,7 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
fetchTeams(accessToken, userID, userRole, currentOrg, setTeams);
|
||||
}
|
||||
}
|
||||
}, [userID, token, accessToken, keys, userRole]);
|
||||
}, [userID, token, accessToken, userRole]);
|
||||
|
||||
useEffect(() => {
|
||||
// check key health - if it's invalid, redirect to login
|
||||
|
||||
@@ -5,29 +5,28 @@ import UserInfoView from "./user_info_view";
|
||||
vi.mock("../networking", () => {
|
||||
const MOCK_USER_DATA = {
|
||||
user_id: "user-123",
|
||||
user_info: {
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test Alias",
|
||||
user_role: "admin",
|
||||
teams: [],
|
||||
models: [],
|
||||
max_budget: 100,
|
||||
budget_duration: "30d",
|
||||
spend: 0,
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
keys: [],
|
||||
user_email: "test@example.com",
|
||||
user_alias: "Test Alias",
|
||||
user_role: "admin",
|
||||
spend: 0,
|
||||
max_budget: 100,
|
||||
models: [],
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: null,
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: "2025-01-02T00:00:00.000Z",
|
||||
sso_user_id: null,
|
||||
teams: [],
|
||||
};
|
||||
|
||||
return {
|
||||
userInfoCall: vi.fn().mockResolvedValue(MOCK_USER_DATA),
|
||||
userGetInfoV2: vi.fn().mockResolvedValue(MOCK_USER_DATA),
|
||||
userDeleteCall: vi.fn(),
|
||||
userUpdateUserCall: vi.fn(),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
invitationCreateCall: vi.fn(),
|
||||
teamInfoCall: vi.fn().mockResolvedValue({ team_alias: "Test Team" }),
|
||||
getProxyBaseUrl: () => "https://litellm.test",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useState } from "react";
|
||||
import { Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, Badge } from "@tremor/react";
|
||||
import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
userInfoCall,
|
||||
userGetInfoV2,
|
||||
UserInfoV2Response,
|
||||
userDeleteCall,
|
||||
userUpdateUserCall,
|
||||
modelAvailableCall,
|
||||
invitationCreateCall,
|
||||
getProxyBaseUrl,
|
||||
teamInfoCall,
|
||||
} from "../networking";
|
||||
import { Button as AntdButton } from "antd";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
@@ -30,23 +32,10 @@ interface UserInfoViewProps {
|
||||
startInEditMode?: boolean;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
user_id: string;
|
||||
user_info: {
|
||||
user_email: string | null;
|
||||
user_alias: string | null;
|
||||
user_role: string | null;
|
||||
teams: any[] | null;
|
||||
models: string[] | null;
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
spend: number | null;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
keys: any[] | null;
|
||||
teams: any[] | null;
|
||||
/** Team info used for display in user detail view */
|
||||
interface TeamDisplayInfo {
|
||||
team_id: string;
|
||||
team_alias: string | null;
|
||||
}
|
||||
|
||||
export default function UserInfoView({
|
||||
@@ -59,7 +48,8 @@ export default function UserInfoView({
|
||||
initialTab = 0,
|
||||
startInEditMode = false,
|
||||
}: UserInfoViewProps) {
|
||||
const [userData, setUserData] = useState<UserInfo | null>(null);
|
||||
const [userData, setUserData] = useState<UserInfoV2Response | null>(null);
|
||||
const [teamDetails, setTeamDetails] = useState<TeamDisplayInfo[]>([]);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -81,9 +71,31 @@ export default function UserInfoView({
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
const data = await userInfoCall(accessToken, userId, userRole || "", false, null, null, true);
|
||||
const data = await userGetInfoV2(accessToken, userId);
|
||||
setUserData(data);
|
||||
|
||||
// Fetch team details for display (team aliases)
|
||||
if (data.teams && data.teams.length > 0) {
|
||||
try {
|
||||
const teamPromises = data.teams.map(async (teamId: string) => {
|
||||
try {
|
||||
const teamData = await teamInfoCall(accessToken, teamId);
|
||||
return {
|
||||
team_id: teamId,
|
||||
team_alias: teamData?.team_alias || null,
|
||||
};
|
||||
} catch {
|
||||
return { team_id: teamId, team_alias: null };
|
||||
}
|
||||
});
|
||||
const teams = await Promise.all(teamPromises);
|
||||
setTeamDetails(teams);
|
||||
} catch {
|
||||
// Fall back to just team IDs
|
||||
setTeamDetails(data.teams.map((id: string) => ({ team_id: id, team_alias: null })));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available models
|
||||
const modelDataResponse = await modelAvailableCall(accessToken, userId, userRole || "");
|
||||
const availableModels = modelDataResponse.data.map((model: any) => model.id);
|
||||
@@ -146,15 +158,12 @@ export default function UserInfoView({
|
||||
// Update local state with new values
|
||||
setUserData({
|
||||
...userData,
|
||||
user_info: {
|
||||
...userData.user_info,
|
||||
user_email: formValues.user_email,
|
||||
user_alias: formValues.user_alias,
|
||||
models: formValues.models,
|
||||
max_budget: formValues.max_budget,
|
||||
budget_duration: formValues.budget_duration,
|
||||
metadata: formValues.metadata,
|
||||
},
|
||||
user_email: formValues.user_email ?? userData.user_email,
|
||||
user_alias: formValues.user_alias ?? userData.user_alias,
|
||||
models: formValues.models ?? userData.models,
|
||||
max_budget: formValues.max_budget ?? userData.max_budget,
|
||||
budget_duration: formValues.budget_duration ?? userData.budget_duration,
|
||||
metadata: formValues.metadata ?? userData.metadata,
|
||||
});
|
||||
|
||||
NotificationsManager.success("User updated successfully");
|
||||
@@ -197,6 +206,20 @@ export default function UserInfoView({
|
||||
}
|
||||
};
|
||||
|
||||
// Build a legacy-compatible shape for UserEditView
|
||||
const userDataForEdit = {
|
||||
user_id: userData.user_id,
|
||||
user_info: {
|
||||
user_email: userData.user_email,
|
||||
user_alias: userData.user_alias,
|
||||
user_role: userData.user_role,
|
||||
models: userData.models,
|
||||
max_budget: userData.max_budget,
|
||||
budget_duration: userData.budget_duration,
|
||||
metadata: userData.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -204,7 +227,7 @@ export default function UserInfoView({
|
||||
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
Back to Users
|
||||
</Button>
|
||||
<Title>{userData.user_info?.user_email || "User"}</Title>
|
||||
<Title>{userData.user_email || "User"}</Title>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<Text className="text-gray-500 font-mono">{userData.user_id}</Text>
|
||||
<AntdButton
|
||||
@@ -243,20 +266,20 @@ export default function UserInfoView({
|
||||
message="Are you sure you want to delete this user? This action cannot be undone."
|
||||
resourceInformationTitle="User Information"
|
||||
resourceInformation={[
|
||||
{ label: "Email", value: userData.user_info?.user_email },
|
||||
{ label: "Email", value: userData.user_email },
|
||||
{ label: "User ID", value: userData.user_id, code: true },
|
||||
{
|
||||
label: "Global Proxy Role",
|
||||
value:
|
||||
(userData.user_info?.user_role && possibleUIRoles?.[userData.user_info.user_role]?.ui_label) ||
|
||||
userData.user_info?.user_role ||
|
||||
(userData.user_role && possibleUIRoles?.[userData.user_role]?.ui_label) ||
|
||||
userData.user_role ||
|
||||
"-",
|
||||
},
|
||||
{
|
||||
label: "Total Spend (USD)",
|
||||
value:
|
||||
userData.user_info?.spend !== null && userData.user_info?.spend !== undefined
|
||||
? userData.user_info.spend.toFixed(2)
|
||||
userData.spend !== null && userData.spend !== undefined
|
||||
? userData.spend.toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
@@ -278,11 +301,11 @@ export default function UserInfoView({
|
||||
<Card>
|
||||
<Text>Spend</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(userData.user_info?.spend || 0, 4)}</Title>
|
||||
<Title>${formatNumberWithCommas(userData.spend || 0, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{userData.user_info?.max_budget !== null
|
||||
? `$${formatNumberWithCommas(userData.user_info.max_budget, 4)}`
|
||||
{userData.max_budget !== null
|
||||
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -291,23 +314,23 @@ export default function UserInfoView({
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="mt-2">
|
||||
{userData.teams?.length && userData.teams?.length > 0 ? (
|
||||
{teamDetails.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userData.teams?.slice(0, isTeamsExpanded ? userData.teams.length : 20).map((team, index) => (
|
||||
<Badge key={index} color="blue" title={team.team_alias}>
|
||||
{team.team_alias}
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<Badge key={index} color="blue" title={team.team_alias || team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
{!isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{userData.teams.length - 20} more
|
||||
+{teamDetails.length - 20} more
|
||||
</Badge>
|
||||
)}
|
||||
{isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<Badge
|
||||
color="gray"
|
||||
className="cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
@@ -323,20 +346,11 @@ export default function UserInfoView({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Virtual Keys</Text>
|
||||
<div className="mt-2">
|
||||
<Text>
|
||||
{userData.keys?.length || 0} {userData.keys?.length === 1 ? "Key" : "Keys"}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Personal Models</Text>
|
||||
<div className="mt-2">
|
||||
{userData.user_info?.models?.length && userData.user_info?.models?.length > 0 ? (
|
||||
userData.user_info?.models?.map((model, index) => <Text key={index}>{model}</Text>)
|
||||
{userData.models?.length && userData.models?.length > 0 ? (
|
||||
userData.models?.map((model, index) => <Text key={index}>{model}</Text>)
|
||||
) : (
|
||||
<Text>All proxy models</Text>
|
||||
)}
|
||||
@@ -357,10 +371,10 @@ export default function UserInfoView({
|
||||
|
||||
{isEditing && userData ? (
|
||||
<UserEditView
|
||||
userData={userData}
|
||||
userData={userDataForEdit}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleUserUpdate}
|
||||
teams={userData.teams}
|
||||
teams={teamDetails}
|
||||
accessToken={accessToken}
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
@@ -389,24 +403,24 @@ export default function UserInfoView({
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Email</Text>
|
||||
<Text>{userData.user_info?.user_email || "Not Set"}</Text>
|
||||
<Text>{userData.user_email || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">User Alias</Text>
|
||||
<Text>{userData.user_info?.user_alias || "Not Set"}</Text>
|
||||
<Text>{userData.user_alias || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Global Proxy Role</Text>
|
||||
<Text>{userData.user_info?.user_role || "Not Set"}</Text>
|
||||
<Text>{userData.user_role || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Created</Text>
|
||||
<Text>
|
||||
{userData.user_info?.created_at
|
||||
? new Date(userData.user_info.created_at).toLocaleString()
|
||||
{userData.created_at
|
||||
? new Date(userData.created_at).toLocaleString()
|
||||
: "Unknown"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -414,8 +428,8 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Last Updated</Text>
|
||||
<Text>
|
||||
{userData.user_info?.updated_at
|
||||
? new Date(userData.user_info.updated_at).toLocaleString()
|
||||
{userData.updated_at
|
||||
? new Date(userData.updated_at).toLocaleString()
|
||||
: "Unknown"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -423,9 +437,9 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Teams</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.teams?.length && userData.teams?.length > 0 ? (
|
||||
{teamDetails.length > 0 ? (
|
||||
<>
|
||||
{userData.teams?.slice(0, isTeamsExpanded ? userData.teams.length : 20).map((team, index) => (
|
||||
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 rounded text-xs"
|
||||
@@ -434,15 +448,15 @@ export default function UserInfoView({
|
||||
{team.team_alias || team.team_id}
|
||||
</span>
|
||||
))}
|
||||
{!isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{!isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(true)}
|
||||
>
|
||||
+{userData.teams.length - 20} more
|
||||
+{teamDetails.length - 20} more
|
||||
</span>
|
||||
)}
|
||||
{isTeamsExpanded && userData.teams?.length > 20 && (
|
||||
{isTeamsExpanded && teamDetails.length > 20 && (
|
||||
<span
|
||||
className="px-2 py-1 bg-gray-100 rounded text-xs cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setIsTeamsExpanded(false)}
|
||||
@@ -460,8 +474,8 @@ export default function UserInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Personal Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.user_info?.models?.length && userData.user_info?.models?.length > 0 ? (
|
||||
userData.user_info?.models?.map((model, index) => (
|
||||
{userData.models?.length && userData.models?.length > 0 ? (
|
||||
userData.models?.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{model}
|
||||
</span>
|
||||
@@ -472,39 +486,24 @@ export default function UserInfoView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Virtual Keys</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{userData.keys?.length && userData.keys?.length > 0 ? (
|
||||
userData.keys.map((key, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-green-100 rounded text-xs">
|
||||
{key.key_alias || key.token}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<Text>No Virtual Keys</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Max Budget</Text>
|
||||
<Text>
|
||||
{userData.user_info?.max_budget !== null && userData.user_info?.max_budget !== undefined
|
||||
? `$${formatNumberWithCommas(userData.user_info.max_budget, 4)}`
|
||||
{userData.max_budget !== null && userData.max_budget !== undefined
|
||||
? `$${formatNumberWithCommas(userData.max_budget, 4)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Budget Reset</Text>
|
||||
<Text>{getBudgetDurationLabel(userData.user_info?.budget_duration ?? null)}</Text>
|
||||
<Text>{getBudgetDurationLabel(userData.budget_duration ?? null)}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Metadata</Text>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||
{JSON.stringify(userData.user_info?.metadata || {}, null, 2)}
|
||||
{JSON.stringify(userData.metadata || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user