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:
yuneng-jiang 2026-04-25 15:05:05 -07:00 committed by GitHub
commit 1dee006423
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 704 additions and 4 deletions

View File

@ -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",

View File

@ -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)]
)

View File

@ -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

View File

@ -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

View 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>
);
}

View File

@ -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],

View File

@ -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);
});

View File

@ -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,

View 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),
});
};