Merge pull request #26520 from BerriAI/litellm_feat-team-my-user-tab
[Feat] Add "My User" tab to team info page
This commit is contained in:
commit
1dee006423
@ -679,6 +679,7 @@ class LiteLLMRoutes(enum.Enum):
|
||||
"/team/permissions_list",
|
||||
"/team/permissions_update",
|
||||
"/team/daily/activity",
|
||||
"/team/{team_id}/members/me",
|
||||
"/model/new",
|
||||
"/model/update",
|
||||
"/model/delete",
|
||||
|
||||
@ -66,6 +66,7 @@ from litellm.proxy.auth.auth_checks import (
|
||||
allowed_route_check_inside_route,
|
||||
can_org_access_model,
|
||||
get_org_object,
|
||||
get_team_membership,
|
||||
get_team_object,
|
||||
get_user_object,
|
||||
)
|
||||
@ -110,6 +111,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
TeamListItem,
|
||||
TeamListResponse,
|
||||
TeamMemberAddResult,
|
||||
TeamMemberInfoResponse,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
)
|
||||
|
||||
@ -3420,6 +3422,126 @@ async def team_info(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/{team_id}/members/me",
|
||||
tags=["team management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=TeamMemberInfoResponse,
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def team_member_me(
|
||||
http_request: Request,
|
||||
team_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get the caller's own team-membership row for the given team.
|
||||
|
||||
Used by internal users to view their own spend, budget, budget reset
|
||||
date, rate limits, and role within a team — without exposing other
|
||||
members' data. The caller is resolved from their API key; the path
|
||||
`/members/me` always refers to that caller.
|
||||
|
||||
Returns 404 if the caller is not a member of the team.
|
||||
|
||||
```
|
||||
curl --location 'http://localhost:4000/team/your_team_id/members/me' \
|
||||
--header 'Authorization: Bearer your_api_key_here'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"error": "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
},
|
||||
)
|
||||
|
||||
caller_user_id = user_api_key_dict.user_id
|
||||
if caller_user_id is None:
|
||||
# Team keys / service-account keys without a user_id can't resolve "me".
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "API key has no associated user_id; cannot resolve 'me' for team membership."
|
||||
},
|
||||
)
|
||||
|
||||
team_table = await get_team_object(
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
caller_user_email = user_api_key_dict.user_email
|
||||
member_role: Optional[str] = None
|
||||
for m in team_table.members_with_roles:
|
||||
# Match by user_id when present, else fall back to email — members
|
||||
# added by email may have user_id=None on the stored entry.
|
||||
if (m.user_id is not None and m.user_id == caller_user_id) or (
|
||||
m.user_email is not None
|
||||
and caller_user_email is not None
|
||||
and m.user_email == caller_user_email
|
||||
):
|
||||
member_role = m.role
|
||||
break
|
||||
|
||||
if member_role is None:
|
||||
# Caller is not a member of this team. Even proxy admins get 404 here —
|
||||
# they can use /team/info to view all members; "me" only resolves for
|
||||
# actual members of the team.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"error": f"User user_id={caller_user_id} is not a member of team_id={team_id}."
|
||||
},
|
||||
)
|
||||
|
||||
membership = await get_team_membership(
|
||||
user_id=caller_user_id,
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
user_row = await get_user_object(
|
||||
user_id=caller_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
)
|
||||
user_email = getattr(user_row, "user_email", None) if user_row is not None else None
|
||||
|
||||
if membership is None:
|
||||
# Member is in members_with_roles but has no membership row yet
|
||||
# (no per-member budget/limits configured). Return defaults.
|
||||
return TeamMemberInfoResponse(
|
||||
user_id=caller_user_id,
|
||||
team_id=team_id,
|
||||
team_alias=team_table.team_alias,
|
||||
role=member_role,
|
||||
user_email=user_email,
|
||||
spend=0.0,
|
||||
total_spend=0.0,
|
||||
budget_id=None,
|
||||
litellm_budget_table=None,
|
||||
)
|
||||
|
||||
return TeamMemberInfoResponse(
|
||||
user_id=caller_user_id,
|
||||
team_id=team_id,
|
||||
team_alias=team_table.team_alias,
|
||||
role=member_role,
|
||||
user_email=user_email,
|
||||
spend=membership.spend,
|
||||
total_spend=membership.total_spend,
|
||||
budget_id=membership.budget_id,
|
||||
litellm_budget_table=membership.litellm_budget_table,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/team/block", tags=["team management"], dependencies=[Depends(user_api_key_auth)]
|
||||
)
|
||||
|
||||
@ -114,3 +114,11 @@ class BulkTeamMemberAddResponse(BaseModel):
|
||||
successful_additions: int
|
||||
failed_additions: int
|
||||
updated_team: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class TeamMemberInfoResponse(LiteLLM_TeamMembership):
|
||||
"""Response for GET /team/{team_id}/members/me — caller's own membership row."""
|
||||
|
||||
role: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
team_alias: Optional[str] = None
|
||||
|
||||
@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@ -16,10 +17,13 @@ sys.path.insert(
|
||||
) # Adds the parent directory to the system path
|
||||
from litellm.proxy._types import UserAPIKeyAuth # Import UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_BudgetTableFull,
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_OrganizationTable,
|
||||
LiteLLM_OrganizationTableWithMembers,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
@ -7228,3 +7232,301 @@ async def test_update_team_rejects_unauthorized_caller():
|
||||
user_api_key_dict=caller,
|
||||
)
|
||||
assert exc_info.value.code == "403"
|
||||
|
||||
|
||||
# ----- /team/{team_id}/members/me -----
|
||||
|
||||
|
||||
def _build_team_for_me(team_id, members):
|
||||
"""Real LiteLLM_TeamTableCachedObj as get_team_object would return."""
|
||||
return LiteLLM_TeamTableCachedObj(
|
||||
team_id=team_id,
|
||||
team_alias="team-vec",
|
||||
members_with_roles=[Member(**m) for m in members],
|
||||
metadata={},
|
||||
models=[],
|
||||
spend=0.0,
|
||||
)
|
||||
|
||||
|
||||
def _build_membership_for_me(user_id, team_id, *, spend=12.34, max_budget=100.0):
|
||||
"""Real LiteLLM_TeamMembership as get_team_membership would return."""
|
||||
return LiteLLM_TeamMembership(
|
||||
user_id=user_id,
|
||||
team_id=team_id,
|
||||
budget_id="b-1",
|
||||
spend=spend,
|
||||
total_spend=spend,
|
||||
litellm_budget_table=LiteLLM_BudgetTableFull(
|
||||
budget_id="b-1",
|
||||
max_budget=max_budget,
|
||||
soft_budget=None,
|
||||
tpm_limit=500,
|
||||
rpm_limit=50,
|
||||
model_max_budget=None,
|
||||
budget_duration="30d",
|
||||
budget_reset_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||
allowed_models=None,
|
||||
created_at=datetime(2026, 4, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _patch_member_me_helpers(*, team, membership=None, user=None):
|
||||
"""Patch the three auth helpers used by team_member_me with AsyncMocks."""
|
||||
return (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_team_object",
|
||||
AsyncMock(return_value=team),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_team_membership",
|
||||
AsyncMock(return_value=membership),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
AsyncMock(return_value=user),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_returns_caller_membership(mock_db_client):
|
||||
"""A team member receives their own membership row, not other members'."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_id = "team-me-1"
|
||||
caller_id = "alice@example.com"
|
||||
other_id = "bob@example.com"
|
||||
caller_auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id=caller_id
|
||||
)
|
||||
|
||||
team = _build_team_for_me(
|
||||
team_id,
|
||||
[
|
||||
{"user_id": caller_id, "user_email": None, "role": "user"},
|
||||
{"user_id": other_id, "user_email": None, "role": "admin"},
|
||||
],
|
||||
)
|
||||
membership = _build_membership_for_me(caller_id, team_id, spend=42.0)
|
||||
user = LiteLLM_UserTable(user_id=caller_id, user_email=caller_id, max_budget=None)
|
||||
|
||||
p_team, p_membership, p_user = _patch_member_me_helpers(
|
||||
team=team, membership=membership, user=user
|
||||
)
|
||||
with p_team, p_membership as mock_get_membership, p_user:
|
||||
response = await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id=team_id,
|
||||
user_api_key_dict=caller_auth,
|
||||
)
|
||||
|
||||
assert response.user_id == caller_id
|
||||
assert response.team_id == team_id
|
||||
assert response.role == "user"
|
||||
assert response.spend == 42.0
|
||||
assert response.team_alias == "team-vec"
|
||||
assert response.litellm_budget_table is not None
|
||||
assert response.litellm_budget_table.max_budget == 100.0
|
||||
# budget_reset_at must survive end-to-end — proves the BudgetTableFull
|
||||
# variant of the Union is selected (created_at is present), not the base
|
||||
# LiteLLM_BudgetTable which would silently strip this field.
|
||||
assert response.litellm_budget_table.budget_reset_at == datetime(
|
||||
2026, 5, 1, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
# Membership lookup must scope to caller_id, not just team_id — proves the
|
||||
# endpoint cannot return another member's row.
|
||||
call_kwargs = mock_get_membership.call_args.kwargs
|
||||
assert call_kwargs["user_id"] == caller_id
|
||||
assert call_kwargs["team_id"] == team_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_matches_email_only_member(mock_db_client):
|
||||
"""
|
||||
Members onboarded by email may have user_id=None on the stored entry —
|
||||
the lookup must fall back to email matching against the caller, otherwise
|
||||
a valid team member gets a false 404.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_id = "team-me-email"
|
||||
caller_id = "u-123"
|
||||
caller_email = "alice@example.com"
|
||||
caller_auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id=caller_id,
|
||||
user_email=caller_email,
|
||||
)
|
||||
|
||||
# Member entry with user_id=None and email matching the caller's email.
|
||||
team = _build_team_for_me(
|
||||
team_id,
|
||||
[{"user_id": None, "user_email": caller_email, "role": "user"}],
|
||||
)
|
||||
membership = _build_membership_for_me(caller_id, team_id, spend=7.0)
|
||||
user = LiteLLM_UserTable(
|
||||
user_id=caller_id, user_email=caller_email, max_budget=None
|
||||
)
|
||||
|
||||
p_team, p_membership, p_user = _patch_member_me_helpers(
|
||||
team=team, membership=membership, user=user
|
||||
)
|
||||
with p_team, p_membership, p_user:
|
||||
response = await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id=team_id,
|
||||
user_api_key_dict=caller_auth,
|
||||
)
|
||||
|
||||
assert response.user_id == caller_id
|
||||
assert response.role == "user"
|
||||
assert response.spend == 7.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_returns_404_for_non_member(mock_db_client):
|
||||
"""A user who is not a member of the team gets 404, regardless of role."""
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_id = "team-me-2"
|
||||
caller_id = "outsider@example.com"
|
||||
caller_auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id=caller_id
|
||||
)
|
||||
|
||||
team = _build_team_for_me(
|
||||
team_id,
|
||||
[{"user_id": "someone_else", "user_email": None, "role": "user"}],
|
||||
)
|
||||
|
||||
p_team, p_membership, p_user = _patch_member_me_helpers(team=team)
|
||||
with p_team, p_membership, p_user:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id=team_id,
|
||||
user_api_key_dict=caller_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_returns_404_for_proxy_admin_not_in_team(
|
||||
mock_db_client, mock_admin_auth
|
||||
):
|
||||
"""
|
||||
Proxy admins get 404 if they are not actually a member of the team.
|
||||
`me` only resolves for actual team members; admins use /team/info instead.
|
||||
"""
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_id = "team-me-3"
|
||||
mock_admin_auth.user_id = "admin_user_999"
|
||||
|
||||
team = _build_team_for_me(
|
||||
team_id,
|
||||
[{"user_id": "someone_else", "user_email": None, "role": "user"}],
|
||||
)
|
||||
|
||||
p_team, p_membership, p_user = _patch_member_me_helpers(team=team)
|
||||
with p_team, p_membership, p_user:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id=team_id,
|
||||
user_api_key_dict=mock_admin_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_returns_defaults_when_no_membership_row(mock_db_client):
|
||||
"""
|
||||
Caller is in members_with_roles but has no LiteLLM_TeamMembership row yet
|
||||
(no per-member budget configured) — return defaults rather than 404.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_id = "team-me-4"
|
||||
caller_id = "newmember@example.com"
|
||||
caller_auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id=caller_id
|
||||
)
|
||||
|
||||
team = _build_team_for_me(
|
||||
team_id,
|
||||
[{"user_id": caller_id, "user_email": None, "role": "user"}],
|
||||
)
|
||||
|
||||
p_team, p_membership, p_user = _patch_member_me_helpers(team=team)
|
||||
with p_team, p_membership, p_user:
|
||||
response = await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id=team_id,
|
||||
user_api_key_dict=caller_auth,
|
||||
)
|
||||
|
||||
assert response.user_id == caller_id
|
||||
assert response.role == "user"
|
||||
assert response.spend == 0.0
|
||||
assert response.litellm_budget_table is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_rejects_team_key_without_user_id(mock_db_client):
|
||||
"""A team key with no user_id can't resolve 'me' — must return 400."""
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
team_key_auth = UserAPIKeyAuth(team_id="team-me-5", user_id=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="team-me-5",
|
||||
user_api_key_dict=team_key_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_me_returns_404_for_unknown_team(mock_db_client):
|
||||
"""Unknown team_id returns 404 — propagated from get_team_object."""
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_me
|
||||
|
||||
caller_auth = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice@example.com"
|
||||
)
|
||||
|
||||
# get_team_object raises 404 directly when the team is missing.
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_team_object",
|
||||
AsyncMock(
|
||||
side_effect=HTTPException(
|
||||
status_code=404, detail={"error": "Team doesn't exist in db."}
|
||||
)
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await team_member_me(
|
||||
http_request=MagicMock(spec=Request),
|
||||
team_id="does-not-exist",
|
||||
user_api_key_dict=caller_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
170
ui/litellm-dashboard/src/components/team/MyUserTab.tsx
Normal file
170
ui/litellm-dashboard/src/components/team/MyUserTab.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { formatBudgetReset } from "@/utils/budgetUtils";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import React from "react";
|
||||
import { useMyTeamMember } from "./useMyTeamMember";
|
||||
|
||||
interface MyUserTabProps {
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
const labelWithTooltip = (label: string, tooltip: string) => (
|
||||
<Space size={4}>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
<Tooltip title={tooltip}>
|
||||
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const formatNumber = (value: number | null | undefined, digits = 4): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
return formatNumberWithCommas(value, digits);
|
||||
};
|
||||
|
||||
const formatRateLimit = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "Unlimited";
|
||||
return formatNumberWithCommas(value, 0);
|
||||
};
|
||||
|
||||
export default function MyUserTab({ teamId }: MyUserTabProps) {
|
||||
const { data, isLoading, error } = useMyTeamMember(teamId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text type="secondary">Loading your membership info…</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text type="danger">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load your membership info for this team."}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Text type="secondary">
|
||||
No membership info available for the current user in this team.
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const budgetTable = data.litellm_budget_table ?? null;
|
||||
const maxBudget = budgetTable?.max_budget ?? null;
|
||||
const spend = data.spend ?? 0;
|
||||
const totalSpend = data.total_spend ?? 0;
|
||||
const tpmLimit = budgetTable?.tpm_limit ?? null;
|
||||
const rpmLimit = budgetTable?.rpm_limit ?? null;
|
||||
const budgetReset = formatBudgetReset(budgetTable?.budget_reset_at);
|
||||
const allowedModels = budgetTable?.allowed_models ?? null;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Card>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Typography.Text type="secondary">User</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text strong>{data.user_email || data.user_id}</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, fontFamily: "monospace" }}>
|
||||
{data.user_id}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Typography.Text type="secondary">Team Role</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Tag color={data.role === "admin" ? "blue" : "default"}>
|
||||
{data.role || "user"}
|
||||
</Tag>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
{labelWithTooltip(
|
||||
"Current Cycle Spend (USD)",
|
||||
"Spend for the current budget cycle. Resets to $0 when the budget window rolls over.",
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
${formatNumber(spend, 4)}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{budgetReset && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text type="secondary">Resets {budgetReset}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
{labelWithTooltip(
|
||||
"Rate Limits",
|
||||
"Your per-member rate limits within this team.",
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text>TPM: {formatRateLimit(tpmLimit)}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text>RPM: {formatRateLimit(rpmLimit)}</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
{labelWithTooltip(
|
||||
"Total Spend (USD)",
|
||||
"Cumulative spend across all budget cycles within this team.",
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
${formatNumber(totalSpend, 4)}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
{labelWithTooltip(
|
||||
"Model Scope",
|
||||
"Models you can access within this team.",
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{allowedModels && allowedModels.length > 0 ? (
|
||||
<Space wrap>
|
||||
{allowedModels.map((m) => (
|
||||
<Tag key={m}>{m}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text>All Team Models</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@ -46,6 +46,7 @@ import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import MemberModal from "./EditMembership";
|
||||
import MemberPermissions from "./member_permissions";
|
||||
import MyUserTab from "./MyUserTab";
|
||||
import {
|
||||
getTeamInfoDefaultTab,
|
||||
getTeamInfoVisibleTabs,
|
||||
@ -853,6 +854,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TEAM_INFO_TAB_KEYS.MY_USER,
|
||||
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER],
|
||||
children: <MyUserTab teamId={teamId} />,
|
||||
},
|
||||
{
|
||||
key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
|
||||
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS],
|
||||
|
||||
@ -11,6 +11,7 @@ describe("team_info_tabs", () => {
|
||||
describe("TEAM_INFO_TAB_LABELS", () => {
|
||||
it("should have label for every tab key", () => {
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER]).toBe("My User");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]).toBe("Virtual Keys");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions");
|
||||
@ -19,15 +20,20 @@ describe("team_info_tabs", () => {
|
||||
});
|
||||
|
||||
describe("getTeamInfoVisibleTabs", () => {
|
||||
it("returns overview and virtual keys when user cannot edit team", () => {
|
||||
it("returns overview, my user, and virtual keys when user cannot edit team", () => {
|
||||
const tabs = getTeamInfoVisibleTabs(false);
|
||||
expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]);
|
||||
expect(tabs).toEqual([
|
||||
TEAM_INFO_TAB_KEYS.OVERVIEW,
|
||||
TEAM_INFO_TAB_KEYS.MY_USER,
|
||||
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns all tabs when user can edit team", () => {
|
||||
const tabs = getTeamInfoVisibleTabs(true);
|
||||
expect(tabs).toEqual([
|
||||
TEAM_INFO_TAB_KEYS.OVERVIEW,
|
||||
TEAM_INFO_TAB_KEYS.MY_USER,
|
||||
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
|
||||
TEAM_INFO_TAB_KEYS.MEMBERS,
|
||||
TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
|
||||
@ -62,6 +68,11 @@ describe("team_info_tabs", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("always returns true for my user tab regardless of edit permission", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MY_USER, false)).toBe(true);
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MY_USER, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for member permissions tab when user cannot edit", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, false)).toBe(false);
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
export const TEAM_INFO_TAB_KEYS = {
|
||||
OVERVIEW: "overview",
|
||||
MY_USER: "my-user",
|
||||
VIRTUAL_KEYS: "virtual-keys",
|
||||
MEMBERS: "members",
|
||||
MEMBER_PERMISSIONS: "member-permissions",
|
||||
@ -13,6 +14,7 @@ export const TEAM_INFO_TAB_KEYS = {
|
||||
|
||||
export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
|
||||
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview",
|
||||
[TEAM_INFO_TAB_KEYS.MY_USER]: "My User",
|
||||
[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "Virtual Keys",
|
||||
[TEAM_INFO_TAB_KEYS.MEMBERS]: "Members",
|
||||
[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions",
|
||||
@ -21,11 +23,15 @@ export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* Returns the list of tab keys that should be visible based on permissions.
|
||||
* - Overview, Virtual Keys: always visible
|
||||
* - Overview, My User, Virtual Keys: always visible
|
||||
* - Members, Member Permissions, Settings: only when canEditTeam is true
|
||||
*/
|
||||
export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] {
|
||||
const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS];
|
||||
const baseTabs = [
|
||||
TEAM_INFO_TAB_KEYS.OVERVIEW,
|
||||
TEAM_INFO_TAB_KEYS.MY_USER,
|
||||
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
|
||||
];
|
||||
if (canEditTeam) {
|
||||
return [
|
||||
...baseTabs,
|
||||
|
||||
74
ui/litellm-dashboard/src/components/team/useMyTeamMember.ts
Normal file
74
ui/litellm-dashboard/src/components/team/useMyTeamMember.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
deriveErrorMessage,
|
||||
getGlobalLitellmHeaderName,
|
||||
getProxyBaseUrl,
|
||||
} from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export interface TeamMemberInfo {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
team_alias?: string | null;
|
||||
role?: string | null;
|
||||
user_email?: string | null;
|
||||
budget_id?: string | null;
|
||||
spend?: number | null;
|
||||
total_spend?: number | null;
|
||||
litellm_budget_table?: {
|
||||
budget_id?: string;
|
||||
soft_budget?: number | null;
|
||||
max_budget?: number | null;
|
||||
max_parallel_requests?: number | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
model_max_budget?: Record<string, number> | null;
|
||||
budget_duration?: string | null;
|
||||
budget_reset_at?: string | null;
|
||||
allowed_models?: string[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const fetchMyTeamMember = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
): Promise<TeamMemberInfo | null> => {
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/${encodeURIComponent(teamId)}/members/me`
|
||||
: `/team/${encodeURIComponent(teamId)}/members/me`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// 404 means the caller is not a team member (e.g. proxy admin viewing
|
||||
// a team they don't belong to). The "My User" tab is always visible so
|
||||
// the fetch fires regardless — return null and let the UI render the
|
||||
// empty state instead of a noisy error.
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(deriveErrorMessage(errorData));
|
||||
}
|
||||
|
||||
return (await response.json()) as TeamMemberInfo;
|
||||
};
|
||||
|
||||
export const useMyTeamMember = (
|
||||
teamId: string | null | undefined,
|
||||
): UseQueryResult<TeamMemberInfo | null> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<TeamMemberInfo | null>({
|
||||
queryKey: ["team", teamId, "members", "me"],
|
||||
queryFn: () => fetchMyTeamMember(accessToken!, teamId!),
|
||||
enabled: Boolean(accessToken && teamId),
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user