From b61537cd0f5a4efc4399fe6d3662414eebcae8e9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 20 Feb 2026 21:14:29 -0800 Subject: [PATCH 1/5] feat: expose user_email in organization members response Add user_email field to LiteLLM_OrganizationMembershipTable with a model_validator that populates it from the nested user object, and update the /organization/info Prisma query to select user_email from the related user record. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/_types.py | 10 ++++++++ .../organization_endpoints.py | 8 +++++- .../test_organization_endpoints.py | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ef471b29e6..10774ffb62 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2437,9 +2437,19 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase): Any ] = None # You might want to replace 'Any' with a more specific type if available litellm_budget_table: Optional[LiteLLM_BudgetTable] = None + user_email: Optional[str] = None model_config = ConfigDict(protected_namespaces=()) + @model_validator(mode="after") + def populate_user_email(self) -> "LiteLLM_OrganizationMembershipTable": + if self.user_email is None and self.user is not None: + if isinstance(self.user, dict): + self.user_email = self.user.get("user_email") + else: + self.user_email = getattr(self.user, "user_email", None) + return self + class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable): """Represents user-controllable params for a LiteLLM_OrganizationTable record""" diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 94cfa82c0c..43c19cff72 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -721,7 +721,13 @@ async def info_organization(organization_id: str): where={"organization_id": organization_id}, include={ "litellm_budget_table": True, - "members": True, + "members": { + "include": { + "user": { + "select": {"user_email": True}, + } + } + }, "teams": True, "object_permission": True, }, diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py index 2a2c37d03c..1f72b147ad 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -535,3 +535,28 @@ async def test_list_organization_filter_by_org_alias(monkeypatch): "members": True, "teams": True, } + + +@pytest.mark.asyncio +async def test_organization_info_includes_user_email(monkeypatch): + """ + Test that GET /organization/info returns user_email in members list. + """ + from litellm.proxy._types import LiteLLM_OrganizationMembershipTable + from datetime import datetime + + # Simulate a membership row with a nested user object that has user_email + raw_membership = { + "user_id": "user_abc", + "organization_id": "org_xyz", + "user_role": "org_admin", + "spend": 0.0, + "budget_id": None, + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow(), + "user": {"user_email": "alice@example.com"}, + "litellm_budget_table": None, + } + + membership = LiteLLM_OrganizationMembershipTable(**raw_membership) + assert membership.user_email == "alice@example.com" From 14e8af94a9aadbf74251cd8e5639eada1c76c495 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 20 Feb 2026 21:18:09 -0800 Subject: [PATCH 2/5] feat: add reusable MemberTable AntD component Co-Authored-By: Claude Sonnet 4.6 --- .../common_components/MemberTable.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/common_components/MemberTable.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx new file mode 100644 index 0000000000..3392158afe --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -0,0 +1,120 @@ +import { Member } from "@/components/networking"; +import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons"; +import { Button, Space, Table, Tag, Tooltip, Typography } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import React from "react"; +import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton"; + +const { Text } = Typography; + +export interface MemberTableProps { + members: Member[]; + canEdit: boolean; + onEdit: (member: Member) => void; + onDelete: (member: Member) => void; + onAddMember?: () => void; + roleColumnTitle?: string; + roleTooltip?: string; + extraColumns?: ColumnsType; + showDeleteForMember?: (member: Member) => boolean; +} + +export default function MemberTable({ + members, + canEdit, + onEdit, + onDelete, + onAddMember, + roleColumnTitle = "Role", + roleTooltip, + extraColumns = [], + showDeleteForMember, +}: MemberTableProps) { + const baseColumns: ColumnsType = [ + { + title: "User Email", + dataIndex: "user_email", + key: "user_email", + render: (email: string | null) => {email || "-"}, + }, + { + title: "User ID", + dataIndex: "user_id", + key: "user_id", + render: (userId: string | null) => + userId === "default_user_id" ? ( + Default Proxy Admin + ) : ( + {userId || "-"} + ), + }, + { + title: roleTooltip ? ( + + {roleColumnTitle} + + + + + ) : ( + roleColumnTitle + ), + dataIndex: "role", + key: "role", + render: (role: string) => ( + + {role?.toLowerCase() === "admin" || role?.toLowerCase() === "org_admin" ? ( + + ) : ( + + )} + {role || "-"} + + ), + }, + ...extraColumns, + { + title: "Actions", + key: "actions", + fixed: "right" as const, + width: 120, + render: (_: unknown, record: Member) => + canEdit ? ( + + onEdit(record)} + /> + {(!showDeleteForMember || showDeleteForMember(record)) && ( + onDelete(record)} + /> + )} + + ) : null, + }, + ]; + + return ( + + record.user_id ?? record.user_email ?? JSON.stringify(record)} + pagination={false} + size="small" + scroll={{ x: "max-content" }} + /> + {onAddMember && canEdit && ( + + )} + + ); +} From a5ee421eefa20d40d3b1a50718d5b48e35b1423d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 20 Feb 2026 21:21:30 -0800 Subject: [PATCH 3/5] refactor: TeamMemberTab uses shared MemberTable component Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/team/TeamMemberTab.tsx | 146 ++++-------------- 1 file changed, 34 insertions(+), 112 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index 09a6b0f468..c72320d08a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -3,14 +3,12 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Member } from "@/components/networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles"; -import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons"; -import { Button, Space, Table, Tag, Tooltip, Typography } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Space, Tooltip, Typography } from "antd"; import type { ColumnsType } from "antd/es/table"; -import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import MemberTable from "@/components/common_components/MemberTable"; import { TeamData } from "./TeamInfo"; -const { Text } = Typography; - interface TeamMemberTabProps { teamData: TeamData; canEditTeam: boolean; @@ -84,48 +82,7 @@ export default function TeamMemberTab({ const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || ""); const isProxyAdmin = isProxyAdminRole(userRole || ""); - const columns: ColumnsType = [ - { - title: "User Email", - dataIndex: "user_email", - key: "user_email", - render: (email: string | null) => ( - {email || "-"} - ), - }, - { - title: "User ID", - dataIndex: "user_id", - key: "user_id", - render: (userId: string | null) => - userId === "default_user_id" ? ( - Default Proxy Admin - ) : ( - {userId} - ), - }, - { - title: ( - - Team Role - - - - - ), - dataIndex: "role", - key: "role", - render: (role: string) => ( - - {role?.toLowerCase() === "admin" ? ( - - ) : ( - - )} - {role} - - ), - }, + const extraColumns: ColumnsType = [ { title: ( @@ -137,9 +94,7 @@ export default function TeamMemberTab({ ), key: "spend", render: (_: unknown, record: Member) => ( - - ${formatNumberWithCommas(getUserSpend(record.user_id), 4)} - + ${formatNumberWithCommas(getUserSpend(record.user_id), 4)} ), }, { @@ -148,9 +103,9 @@ export default function TeamMemberTab({ render: (_: unknown, record: Member) => { const budget = getUserBudget(record.user_id); return ( - + {budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"} - + ); }, }, @@ -165,69 +120,36 @@ export default function TeamMemberTab({ ), key: "rate_limits", render: (_: unknown, record: Member) => ( - {getUserRateLimits(record.user_id)} + {getUserRateLimits(record.user_id)} ), }, - { - title: "Actions", - key: "actions", - fixed: "right", - width: 120, - render: (_: unknown, record: Member) => - canEditTeam ? ( -
- { - const membership = teamData.team_memberships.find( - (tm) => tm.user_id === record.user_id - ); - const enhancedMember = { - ...record, - max_budget_in_team: - membership?.litellm_budget_table?.max_budget || null, - tpm_limit: - membership?.litellm_budget_table?.tpm_limit || null, - rpm_limit: - membership?.litellm_budget_table?.rpm_limit || null, - }; - setSelectedEditMember(enhancedMember); - setIsEditMemberModalVisible(true); - }} - /> - {(isProxyAdmin || - (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && ( - handleMemberDelete(record)} - /> - )} -
- ) : null, - }, ]; return ( -
-
record.user_id || String(index)} - pagination={false} - size="small" - scroll={{ x: "max-content" }} - /> - - + { + const membership = teamData.team_memberships.find( + (tm) => tm.user_id === record.user_id + ); + const enhancedMember = { + ...record, + max_budget_in_team: membership?.litellm_budget_table?.max_budget || null, + tpm_limit: membership?.litellm_budget_table?.tpm_limit || null, + rpm_limit: membership?.litellm_budget_table?.rpm_limit || null, + }; + setSelectedEditMember(enhancedMember); + setIsEditMemberModalVisible(true); + }} + onDelete={handleMemberDelete} + onAddMember={() => setIsAddMemberModalVisible(true)} + roleColumnTitle="Team Role" + roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." + extraColumns={extraColumns} + showDeleteForMember={() => + isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + } + /> ); -}; +} From 4597d3434401072a2fbf2ee082f806d05954d6b9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 20 Feb 2026 21:25:59 -0800 Subject: [PATCH 4/5] feat: org info page - AntD tabs, MemberTable with user_email Co-Authored-By: Claude Sonnet 4.6 --- .../organization/organization_view.tsx | 615 +++++++++--------- 1 file changed, 293 insertions(+), 322 deletions(-) diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 9b4e9080f2..d009f8b534 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -1,31 +1,21 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { createTeamAliasMap } from "@/utils/teamUtils"; -import { ArrowLeftIcon, PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Card, Grid, - Icon, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, Text, TextInput, Title, Button as TremorButton, } from "@tremor/react"; -import { Button, Form, Input, Select } from "antd"; +import { Button, Form, Input, Select, Tabs, Typography } from "antd"; +import type { ColumnsType } from "antd/es/table"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; +import MemberTable from "../common_components/MemberTable"; import UserSearchModal from "../common_components/user_search_modal"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import { ModelSelect } from "../ModelSelect/ModelSelect"; @@ -224,6 +214,41 @@ const OrganizationInfoView: React.FC = ({ } }; + const orgExtraColumns: ColumnsType = [ + { + title: "Spend (USD)", + key: "spend", + render: (_: unknown, record: Member) => { + const orgMember = + record.user_id != null + ? (orgData.members || []).find((m) => m.user_id === record.user_id) + : undefined; + return ( + + ${formatNumberWithCommas(orgMember?.spend ?? 0, 4)} + + ); + }, + }, + { + title: "Created At", + key: "created_at", + render: (_: unknown, record: Member) => { + const orgMember = + record.user_id != null + ? (orgData.members || []).find((m) => m.user_id === record.user_id) + : undefined; + return ( + + {orgMember?.created_at + ? new Date(orgMember.created_at).toLocaleString() + : "-"} + + ); + }, + }, + ]; + return (
@@ -248,325 +273,271 @@ const OrganizationInfoView: React.FC = ({
- - - Overview - Members - Settings - + + + Organization Details +
+ Created: {new Date(orgData.created_at).toLocaleDateString()} + Updated: {new Date(orgData.updated_at).toLocaleDateString()} + Created By: {orgData.created_by} +
+
- - {/* Overview Panel */} - - - - Organization Details -
- Created: {new Date(orgData.created_at).toLocaleDateString()} - Updated: {new Date(orgData.updated_at).toLocaleDateString()} - Created By: {orgData.created_by} -
-
- - - Budget Status -
- ${formatNumberWithCommas(orgData.spend, 4)} - - of{" "} - {orgData.litellm_budget_table.max_budget === null - ? "Unlimited" - : `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`} - - {orgData.litellm_budget_table.budget_duration && ( - Reset: {orgData.litellm_budget_table.budget_duration} - )} -
-
- - - Rate Limits -
- TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"} - RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"} - {orgData.litellm_budget_table.max_parallel_requests && ( - Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests} - )} -
-
- - - Models -
- {orgData.models.length === 0 ? ( - All proxy models - ) : ( - orgData.models.map((model, index) => ( - - {model} - - )) - )} -
-
- - Teams -
- {orgData.teams?.map((team, index) => ( - - {teamAliasMap[team.team_id] || team.team_id} - - ))} -
-
- - -
-
- - -
- -
- - - User ID - Role - Spend - Created At - - - - - - {orgData.members && orgData.members.length > 0 ? ( - orgData.members.map((member, index) => ( - - - {member.user_id} - - - {member.user_role} - - - ${formatNumberWithCommas(member.spend, 4)} - - - {new Date(member.created_at).toLocaleString()} - - - {canEditOrg && ( - <> - { - setSelectedEditMember({ - role: member.user_role, - user_email: member.user_email, - user_id: member.user_id, - }); - setIsEditMemberModalVisible(true); - }} - /> - { - handleMemberDelete(member); - }} - /> - - )} - - - )) - ) : ( - - - No members found - - + + Budget Status +
+ ${formatNumberWithCommas(orgData.spend, 4)} + + of{" "} + {orgData.litellm_budget_table.max_budget === null + ? "Unlimited" + : `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`} + + {orgData.litellm_budget_table.budget_duration && ( + Reset: {orgData.litellm_budget_table.budget_duration} )} - -
- - {canEditOrg && ( - { - setIsAddMemberModalVisible(true); - }} - > - Add Member - - )} - - - - {/* Settings Panel */} - - -
- Organization Settings - {canEditOrg && !isEditing && ( - setIsEditing(true)}>Edit Settings - )} -
- - {isEditing ? ( -
- - - - - - form.setFieldValue("models", values)} - context="organization" - options={{ - includeSpecialOptions: true, - showAllProxyModelsOverride: true, - }} - /> - - - - - - - - - - - - - - - - - - - - form.setFieldValue("vector_stores", values)} - value={form.getFieldValue("vector_stores")} - accessToken={accessToken || ""} - placeholder="Select vector stores" - /> - - - - form.setFieldValue("mcp_servers_and_groups", values)} - value={form.getFieldValue("mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers and access groups" - /> - - - - - - -
-
- setIsEditing(false)} disabled={isOrgSaving}> - Cancel - - - Save Changes - -
-
- ) : ( -
-
- Organization Name -
{orgData.organization_alias}
+ + + + Rate Limits +
+ TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"} + RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"} + {orgData.litellm_budget_table.max_parallel_requests && ( + Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests} + )}
-
- Organization ID -
{orgData.organization_id}
-
-
- Created At -
{new Date(orgData.created_at).toLocaleString()}
-
-
- Models -
- {orgData.models.map((model, index) => ( + + + + Models +
+ {orgData.models.length === 0 ? ( + All proxy models + ) : ( + orgData.models.map((model, index) => ( {model} - ))} -
+ )) + )}
-
- Rate Limits -
TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}
-
RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}
-
-
- Budget -
- Max:{" "} - {orgData.litellm_budget_table.max_budget !== null - ? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}` - : "No Limit"} -
-
Reset: {orgData.litellm_budget_table.budget_duration || "Never"}
+ + + Teams +
+ {orgData.teams?.map((team, index) => ( + + {teamAliasMap[team.team_id] || team.team_id} + + ))}
+
- + + + ), + }, + { + key: "members", + label: "Members", + children: ( +
+ ({ + role: m.user_role || "", + user_id: m.user_id, + user_email: m.user_email, + }))} + canEdit={canEditOrg} + onEdit={(member) => { + setSelectedEditMember(member); + setIsEditMemberModalVisible(true); + }} + onDelete={(member) => handleMemberDelete(member)} + onAddMember={() => setIsAddMemberModalVisible(true)} + roleColumnTitle="Organization Role" + extraColumns={orgExtraColumns} + /> +
+ ), + }, + { + key: "settings", + label: "Settings", + children: ( + +
+ Organization Settings + {canEditOrg && !isEditing && ( + setIsEditing(true)}>Edit Settings + )}
- )} -
- - - + + {isEditing ? ( +
+ + + + + + form.setFieldValue("models", values)} + context="organization" + options={{ + includeSpecialOptions: true, + showAllProxyModelsOverride: true, + }} + /> + + + + + + + + + + + + + + + + + + + + form.setFieldValue("vector_stores", values)} + value={form.getFieldValue("vector_stores")} + accessToken={accessToken || ""} + placeholder="Select vector stores" + /> + + + + form.setFieldValue("mcp_servers_and_groups", values)} + value={form.getFieldValue("mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers and access groups" + /> + + + + + + +
+
+ setIsEditing(false)} disabled={isOrgSaving}> + Cancel + + + Save Changes + +
+
+
+ ) : ( +
+
+ Organization Name +
{orgData.organization_alias}
+
+
+ Organization ID +
{orgData.organization_id}
+
+
+ Created At +
{new Date(orgData.created_at).toLocaleString()}
+
+
+ Models +
+ {orgData.models.map((model, index) => ( + + {model} + + ))} +
+
+
+ Rate Limits +
TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}
+
RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}
+
+
+ Budget +
+ Max:{" "} + {orgData.litellm_budget_table.max_budget !== null + ? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}` + : "No Limit"} +
+
Reset: {orgData.litellm_budget_table.budget_duration || "Never"}
+
+ + +
+ )} + + ), + }, + ]} + /> setIsAddMemberModalVisible(false)} From ffc673da09c25811646a2738df93a63caddb7c80 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 20 Feb 2026 21:40:23 -0800 Subject: [PATCH 5/5] fix: use include user:True instead of nested select for Prisma Python compat Prisma Python client does not support nested select within include. Use include user:True to fetch the full user object; model_validator extracts user_email from it. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/management_endpoints/organization_endpoints.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 43c19cff72..1c19c4ef31 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -723,9 +723,7 @@ async def info_organization(organization_id: str): "litellm_budget_table": True, "members": { "include": { - "user": { - "select": {"user_email": True}, - } + "user": True, } }, "teams": True,