diff --git a/.github/pr-assets/customer-hierarchy-settings/before.png b/.github/pr-assets/customer-hierarchy-settings/before.png new file mode 100644 index 00000000000..563c231cfbe Binary files /dev/null and b/.github/pr-assets/customer-hierarchy-settings/before.png differ diff --git a/.github/pr-assets/customer-hierarchy-settings/delete-confirmation.png b/.github/pr-assets/customer-hierarchy-settings/delete-confirmation.png new file mode 100644 index 00000000000..180e474ad62 Binary files /dev/null and b/.github/pr-assets/customer-hierarchy-settings/delete-confirmation.png differ diff --git a/.github/pr-assets/customer-hierarchy-settings/member-settings.png b/.github/pr-assets/customer-hierarchy-settings/member-settings.png new file mode 100644 index 00000000000..fa69f2eca7e Binary files /dev/null and b/.github/pr-assets/customer-hierarchy-settings/member-settings.png differ diff --git a/.github/pr-assets/customer-hierarchy-settings/teams.png b/.github/pr-assets/customer-hierarchy-settings/teams.png new file mode 100644 index 00000000000..c0f8f7f5f67 Binary files /dev/null and b/.github/pr-assets/customer-hierarchy-settings/teams.png differ diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx new file mode 100644 index 00000000000..c89cdf3a7b6 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { ChevronRight, Pencil } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData"; +import { ErrorState } from "@/components/shared/ErrorState"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { uiHref } from "@/utils/uiHref"; +import CustomerHierarchyNavigation, { customerHierarchyHref } from "./CustomerHierarchyNavigation"; +import CustomerHierarchySettings from "./CustomerHierarchySettings"; +import { customerEntityId, customerEntityLabel } from "./customerHierarchyForms"; +import useCustomerHierarchy, { type CustomerHierarchyContext } from "./useCustomerHierarchy"; +import type { CustomerHierarchyScope } from "./customerHierarchyApi"; + +type Hierarchy = ReturnType; +interface ScopeProps { + hierarchy: Hierarchy; + selected: CustomerHierarchyContext; + scope: CustomerHierarchyScope; +} + +function HierarchyHeader({ hierarchy, selected }: Pick) { + const { t } = useTranslation(); + const entity = selected.entity; + const team = selected.team; + const label = customerEntityLabel(entity); + const kindLabel = { + organization: t("customerPortal.hierarchy.organization"), + team: t("customerPortal.hierarchy.team"), + member: t("customerPortal.hierarchy.member"), + }[entity.kind]; + return ( +
+ +

{kindLabel}

+

{label}

+

{t("customerPortal.hierarchy.subtitle")}

+

{customerEntityId(entity)}

+
+ ); +} + +function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) { + const list = hierarchy.children.data; + if (list?.kind === "teams") + return list.data.map((team) => ({ + id: team.customer_team_id, + label: team.alias || team.customer_team_id, + spend: team.spend, + budget: team.budget?.max_budget, + open: () => hierarchy.openTeam(team.customer_team_id), + edit: () => hierarchy.openTeam(team.customer_team_id, true), + usageScope: { org: scope.org, team: team.customer_team_id, member: null }, + })); + if (list?.kind === "members") + return list.data.map((member) => ({ + id: member.customer_member_id, + label: member.alias || member.customer_member_id, + spend: member.spend, + budget: member.effective_budget.max_budget, + open: () => hierarchy.openMember(member.customer_member_id), + edit: () => hierarchy.openMember(member.customer_member_id), + usageScope: { org: scope.org, team: selected.team?.customer_team_id ?? null, member: member.customer_member_id }, + })); + return []; +} + +function HierarchyChildTable(props: ScopeProps) { + const { t } = useTranslation(); + const { hierarchy } = props; + const { children, childTab, page } = hierarchy; + const childLabel = childTab === "teams" ? t("customerPortal.hierarchy.teams") : t("customerPortal.hierarchy.members"); + if (children.isError) + return ( + void children.refetch()} + /> + ); + if (children.isPending) + return ( +

+ {t("customerPortal.hierarchy.loading")} +

+ ); + const rows = hierarchyRows(props); + const pages = Math.max(1, children.data.meta.total_pages); + return ( + <> + {" "} +
+

{childLabel}

+

+ {t(childTab === "teams" ? "customerPortal.hierarchy.teamsHint" : "customerPortal.hierarchy.membersHint")} +

+
+ + + + {t("customerPortal.hierarchy.name")} + {t("customerPortal.hierarchy.spend")} + {t("customerPortal.hierarchy.budget")} + + {t("customerPortal.hierarchy.settings")} + + + + + {rows.map((row) => ( + + + + {row.id} + + {formatUsd(row.spend)} + + {row.budget == null ? t("customerPortal.hierarchy.noBudget") : formatUsd(row.budget)} + + +
+ + {t("customerPortal.hierarchy.viewUsage")} + + +
+
+
+ ))} + {rows.length === 0 && ( + + + {t( + childTab === "teams" + ? "customerPortal.hierarchy.emptyTeams" + : "customerPortal.hierarchy.emptyMembers", + )} + + + )} +
+
+ {pages > 1 && ( +
+ {t("customerPortal.hierarchy.pagination", { page, pages })} +
+ + +
+
+ )} + + ); +} + +export default function CustomerHierarchyManager() { + const { t } = useTranslation(); + const router = useRouter(); + const hierarchy = useCustomerHierarchy(); + const { scope, context, selected, tab } = hierarchy; + + if (hierarchy.isLoading) + return ( +

+ {t("customerPortal.hierarchy.loading")} +

+ ); + if (!hierarchy.canManage) return ; + if (!scope) + return ( +
+

{t("customerPortal.hierarchy.title")}

+

{t("customerPortal.hierarchy.missingOrganization")}

+ + {t("customerPortal.hierarchy.backToCustomers")} + +
+ ); + if (context.isError) + return ( + void context.refetch()} + /> + ); + if (!selected) + return ( +

+ {t("customerPortal.hierarchy.loading")} +

+ ); + + const entity = selected.entity; + const currentScope = { + org: scope.org, + team: selected.team?.customer_team_id ?? null, + member: selected.member?.customer_member_id ?? null, + }; + const onDeleted = () => { + hierarchy.refresh(); + if (selected.member && selected.team) hierarchy.openTeam(selected.team.customer_team_id); + else if (selected.team) hierarchy.openOrganization(); + else router.push(uiHref("customers")); + }; + + return ( +
+ + +
+ {tab === "settings" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyNavigation.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyNavigation.tsx new file mode 100644 index 00000000000..d92bdb2b328 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyNavigation.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cva.config"; +import { uiHref } from "@/utils/uiHref"; +import type { CustomerUsageScope } from "./customerUsageApi"; + +type CustomerHierarchyView = "usage" | "teams" | "members" | "settings"; +const LABELS = { + usage: "customerPortal.hierarchy.usage", + teams: "customerPortal.hierarchy.teams", + members: "customerPortal.hierarchy.members", + settings: "customerPortal.hierarchy.settings", +} as const; + +export function customerHierarchyHref(page: "usage" | "manage", scope: CustomerUsageScope): string { + const query = new URLSearchParams({ org: scope.org }); + if (scope.team) query.set("team", scope.team); + if (scope.member) query.set("member", scope.member); + return `${uiHref(`customers/${page}`)}?${query}`; +} + +export default function CustomerHierarchyNavigation({ + scope, + active, +}: { + scope: CustomerUsageScope; + active: CustomerHierarchyView; +}) { + const { t } = useTranslation(); + const pages: readonly CustomerHierarchyView[] = scope.member + ? ["usage", "settings"] + : ["usage", scope.team ? "members" : "teams", "settings"]; + return ( + + ); +} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.test.tsx new file mode 100644 index 00000000000..16b7cf1760c --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.test.tsx @@ -0,0 +1,202 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerBaseUrlGetter } from "@/lib/http/runtime"; +import { act, chooseSelectOption, fireEvent, render, screen, waitFor, within } from "../../../../../tests/test-utils"; +import CustomerHierarchySettings from "./CustomerHierarchySettings"; +import type { Organization, Member } from "./customerHierarchyForms"; + +const audit = { + created_at: "2026-11-01T00:00:00Z", + updated_at: "2026-11-01T00:00:00Z", + created_by: "admin", + updated_by: "admin", + spend: 5, +}; +const organization: Organization = { + ...audit, + customer_org_id: "org-1", + alias: "Example organization", + default_budget_id: "template-1", + default_budgets: [ + { budget_id: "template-1", name: "Standard", is_default: true, max_budget: 10 }, + { budget_id: "template-2", name: "Pro", is_default: false, max_budget: 50 }, + ], + budget: { + max_budget: 100, + soft_budget: 80, + budget_duration: "1mo", + tpm_limit: 200, + rpm_limit: 10, + allowed_models: ["openai/gpt-4.1"], + }, +}; +const member: Member = { + ...audit, + customer_member_id: "member-1", + parent_team_id: "team-1", + alias: "Member One", + budget_template_id: "template-1", + budget_override: { max_budget: 20 }, + effective_budget: { max_budget: 20 }, + budget_override_expires_at: "2026-12-01T12:00:00Z", +}; +const fetchMock = vi.fn(); +const onSaved = vi.fn(); +const onDeleted = vi.fn(); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +const lastRequest = (): Request => { + const request = fetchMock.mock.calls.at(-1)?.[0]; + if (!(request instanceof Request)) throw new Error("Expected a request through the shared HTTP client"); + return request; +}; + +describe("CustomerHierarchySettings", () => { + beforeEach(() => { + registerBaseUrlGetter(() => "http://localhost"); + fetchMock.mockReset(); + onSaved.mockReset(); + onDeleted.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("should render the existing organization settings and read-only identifier", () => { + render( + , + ); + expect(screen.getByRole("textbox", { name: "Display name" })).toHaveValue("Example organization"); + expect(screen.getByRole("textbox", { name: "Identifier" })).toHaveAttribute("readonly"); + expect(screen.getByRole("spinbutton", { name: "Spend limit (USD)" })).toHaveValue(100); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + }); + + it("should preserve other limits when saving a zero organization spend limit", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(json(organization)); + render( + , + ); + act(() => + fireEvent.change(screen.getByRole("spinbutton", { name: "Spend limit (USD)" }), { target: { value: "0" } }), + ); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(lastRequest().method).toBe("PATCH"); + expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_organizations/org-1"); + expect(await lastRequest().clone().json()).toEqual({ budget: { ...organization.budget, max_budget: 0 } }); + }); + + it("should show friendly template names while sending the selected template identifier", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(json(member)); + render( + , + ); + const template = screen.getByRole("combobox", { name: "Budget template" }); + expect(template).toHaveTextContent("Standard (default)"); + expect(template).not.toHaveTextContent("template-1"); + await chooseSelectOption(user, template, "Pro"); + expect(template).toHaveTextContent("Pro"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(await lastRequest().clone().json()).toEqual({ budget_template_id: "template-2" }); + }); + + it("should identify an unavailable stored template instead of displaying No template", () => { + render( + , + ); + expect(screen.getByRole("combobox", { name: "Budget template" })).toHaveTextContent( + "Unavailable template (retired-template)", + ); + }); + + it("should clear the member override and its expiry without changing the template", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(json(member)); + render( + , + ); + await user.click(screen.getByRole("checkbox", { name: "Override the template budget" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_members/member-1"); + expect(await lastRequest().clone().json()).toEqual({ budget_override: null, budget_override_expires_at: null }); + }); + + it("should keep edited values and show the API error when a save fails", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(json({ detail: "Database not connected." }, 503)); + render( + , + ); + const alias = screen.getByRole("textbox", { name: "Display name" }); + await user.clear(alias); + await user.type(alias, "Updated organization"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Database not connected."); + expect(alias).toHaveValue("Updated organization"); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it("should require confirmation and retain the delete dialog when a parent has children", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValueOnce( + json({ detail: "Delete the customer's children before deleting the parent." }, 409), + ); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Delete Organization" })); + const dialog = await screen.findByRole("dialog", { name: "Delete Example organization?" }); + expect(fetchMock).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole("button", { name: "Delete Organization" })); + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Delete the customer's children before deleting the parent.", + ); + expect(dialog).toBeInTheDocument(); + expect(onDeleted).not.toHaveBeenCalled(); + expect(lastRequest().method).toBe("DELETE"); + expect(new URL(lastRequest().url).pathname).toBe("/management/v1/customer_organizations/org-1"); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + await user.click(within(dialog).getByRole("button", { name: "Delete Organization" })); + await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce()); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.tsx new file mode 100644 index 00000000000..9a95aeeaf79 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchySettings.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Trash2 } from "lucide-react"; +import { fetchClient } from "@/lib/http/api"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + buildCustomerHierarchyPatch, + customerEntityId, + customerEntityLabel, + customerHierarchyFields, + type CustomerHierarchyEntity, + type CustomerHierarchyFields, + type CustomerHierarchyPatch, + type Organization, +} from "./customerHierarchyForms"; + +interface Props { + entity: CustomerHierarchyEntity; + organization: Organization; + onSaved: () => void; + onDeleted: () => void; +} + +const saveEntity = async (id: string, patch: CustomerHierarchyPatch): Promise => { + switch (patch.kind) { + case "organization": + await fetchClient.PATCH("/management/v1/customer_organizations/{customer_org_id}", { + params: { path: { customer_org_id: id } }, + body: patch.body, + }); + return; + case "team": + await fetchClient.PATCH("/management/v1/customer_teams/{customer_team_id}", { + params: { path: { customer_team_id: id } }, + body: patch.body, + }); + return; + case "member": + await fetchClient.PATCH("/management/v1/customer_members/{customer_member_id}", { + params: { path: { customer_member_id: id } }, + body: patch.body, + }); + } +}; + +const deleteEntity = async (entity: CustomerHierarchyEntity): Promise => { + const id = customerEntityId(entity); + switch (entity.kind) { + case "organization": + await fetchClient.DELETE("/management/v1/customer_organizations/{customer_org_id}", { + params: { path: { customer_org_id: id } }, + }); + return; + case "team": + await fetchClient.DELETE("/management/v1/customer_teams/{customer_team_id}", { + params: { path: { customer_team_id: id } }, + }); + return; + case "member": + await fetchClient.DELETE("/management/v1/customer_members/{customer_member_id}", { + params: { path: { customer_member_id: id } }, + }); + } +}; + +const KIND_LABELS = { + organization: "customerPortal.hierarchy.organization", + team: "customerPortal.hierarchy.team", + member: "customerPortal.hierarchy.member", +} as const; + +const ERROR_LABELS = { + invalidNumber: "customerPortal.hierarchy.invalidNumber", + invalidExpiry: "customerPortal.hierarchy.invalidExpiry", + budgetRequired: "customerPortal.hierarchy.budgetRequired", +} as const; + +function SettingsForm({ entity, organization, onSaved, onDeleted }: Props) { + const { t } = useTranslation(); + const initial = customerHierarchyFields(entity); + const [fields, setFields] = useState(initial); + const [pending, setPending] = useState<"save" | "delete" | null>(null); + const [error, setError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const dirty = JSON.stringify(fields) !== JSON.stringify(initial); + const id = customerEntityId(entity); + const kind = t(KIND_LABELS[entity.kind]); + const update = (key: K, value: CustomerHierarchyFields[K]) => + setFields((current) => ({ ...current, [key]: value })); + const templateLabel = (templateId: string): string => { + if (!templateId) return t("customerPortal.hierarchy.noTemplate"); + const template = organization.default_budgets.find((budget) => budget.budget_id === templateId); + if (!template) return t("customerPortal.hierarchy.unknownTemplate", { id: templateId }); + if (templateId === organization.default_budget_id) + return t("customerPortal.hierarchy.defaultTemplate", { name: template.name }); + return template.name; + }; + + const save = async (event: FormEvent) => { + event.preventDefault(); + const result = buildCustomerHierarchyPatch(entity, fields); + if (!result.ok) { + setError(t(ERROR_LABELS[result.error])); + return; + } + setPending("save"); + setError(null); + try { + await saveEntity(id, result.patch); + toast.success(t("customerPortal.hierarchy.saved")); + onSaved(); + } catch (cause) { + setError(extractProxyErrorMessage(cause)); + } finally { + setPending(null); + } + }; + + const remove = async () => { + setPending("delete"); + setDeleteError(null); + try { + await deleteEntity(entity); + setDeleteOpen(false); + onDeleted(); + } catch (cause) { + setDeleteError(extractProxyErrorMessage(cause)); + } finally { + setPending(null); + } + }; + + const numericFields = [ + ["maxBudget", "customerPortal.hierarchy.maxBudget", "any"], + ["softBudget", "customerPortal.hierarchy.softBudget", "any"], + ["tpm", "customerPortal.hierarchy.tpm", "1"], + ["rpm", "customerPortal.hierarchy.rpm", "1"], + ] as const; + + return ( +
+
void save(event)} className="space-y-7"> +
+
+
+

+ {t("customerPortal.hierarchy.profileTitle")} +

+

{t("customerPortal.hierarchy.profileHint")}

+
+
+
+ + update("alias", event.target.value)} + /> +
+
+ + +
+
+
+ +
+
+

+ {t("customerPortal.hierarchy.budgetTitle")} +

+

{t("customerPortal.hierarchy.budgetHint")}

+
+ {entity.kind === "member" && ( +
+ + +
+ )} +
+
+ update("budgetEnabled", checked)} + /> + +
+ {entity.kind === "member" && ( +

{t("customerPortal.hierarchy.overrideHint")}

+ )} +
+ {fields.budgetEnabled && ( +
+
+ {numericFields.map(([name, label, step]) => ( +
+ + update(name, event.target.value)} + /> +
+ ))} +
+ + update("budgetDuration", event.target.value)} + aria-describedby="hierarchy-duration-hint" + /> +

+ {t("customerPortal.hierarchy.budgetDurationHint")} +

+
+ {entity.kind === "member" && ( +
+ + update("overrideExpiry", event.target.value)} + aria-describedby="hierarchy-expiry-hint" + /> +

+ {t("customerPortal.hierarchy.overrideExpiryHint")} +

+
+ )} +
+
+ +