diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 00851aee45..92c920ca59 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 7bf7720d82..f254fea3e7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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)] ) diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index b1dabf96e6..cb27fd5230 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 65187fb52d..ffad052a2c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/team/MyUserTab.tsx b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx new file mode 100644 index 0000000000..861acd4ae7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/MyUserTab.tsx @@ -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) => ( + + {label} + + + + +); + +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 ( + + Loading your membership info… + + ); + } + + if (error) { + return ( + + + {error instanceof Error + ? error.message + : "Failed to load your membership info for this team."} + + + ); + } + + if (!data) { + return ( + + + No membership info available for the current user in this team. + + + ); + } + + 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 ( + + + + + User +
+ {data.user_email || data.user_id} +
+ + {data.user_id} + + + + Team Role +
+ + {data.role || "user"} + +
+ +
+
+ + + + + {labelWithTooltip( + "Current Cycle Spend (USD)", + "Spend for the current budget cycle. Resets to $0 when the budget window rolls over.", + )} +
+ + ${formatNumber(spend, 4)} + + + of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`} + +
+ {budgetReset && ( +
+ Resets {budgetReset} +
+ )} +
+ + + + + {labelWithTooltip( + "Rate Limits", + "Your per-member rate limits within this team.", + )} +
+ TPM: {formatRateLimit(tpmLimit)} +
+ RPM: {formatRateLimit(rpmLimit)} +
+
+ + + + + {labelWithTooltip( + "Total Spend (USD)", + "Cumulative spend across all budget cycles within this team.", + )} +
+ + ${formatNumber(totalSpend, 4)} + +
+
+ + + + + {labelWithTooltip( + "Model Scope", + "Models you can access within this team.", + )} +
+ {allowedModels && allowedModels.length > 0 ? ( + + {allowedModels.map((m) => ( + {m} + ))} + + ) : ( + All Team Models + )} +
+
+ +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 302a3a02f1..ef98054c08 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = ({ ), }, + { + key: TEAM_INFO_TAB_KEYS.MY_USER, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER], + children: , + }, { key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS], diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts index 8b9a0402c9..d9d9595640 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts @@ -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); }); diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts index dd0e54baf3..bd3753d4b0 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts @@ -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 = { [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 = { /** * 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, diff --git a/ui/litellm-dashboard/src/components/team/useMyTeamMember.ts b/ui/litellm-dashboard/src/components/team/useMyTeamMember.ts new file mode 100644 index 0000000000..a6406104ea --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/useMyTeamMember.ts @@ -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 | null; + budget_duration?: string | null; + budget_reset_at?: string | null; + allowed_models?: string[] | null; + } | null; +} + +const fetchMyTeamMember = async ( + accessToken: string, + teamId: string, +): Promise => { + 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 => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: ["team", teamId, "members", "me"], + queryFn: () => fetchMyTeamMember(accessToken!, teamId!), + enabled: Boolean(accessToken && teamId), + }); +};