From 0fccd09cb6dbee385102cf11b98233a160850b73 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 21 Aug 2026 20:55:20 -0700 Subject: [PATCH 1/2] fix(ui): render team and org tpm/rpm limits of 0 as 0 instead of Unlimited A tpm_limit or rpm_limit of 0 is a hard block on the backend (every request 429s) and only null means unlimited, but the team and organization views rendered both as "Unlimited" (and a team-member limit of 0 as "No Limit") because every display site used a falsy || fallback. The team member edit dialog also seeded its form with `tpm_limit || null`, so opening Edit Member on a member stored with 0 and clicking Save sent null to /team/member_update and silently turned the hard block into unlimited Every limit display site in TeamInfo, organization_view, the organizations list cell and the team members table now uses a nullish check, and both member form seeding paths keep 0 for max_budget_in_team, tpm_limit and rpm_limit. Regression tests cover each site and the existing memberFormValues test that asserted 0 -> null is flipped to assert 0 survives Resolves LIT-5760 --- .../_components/OrganizationsTable.test.tsx | 14 ++++++++ .../_components/OrganizationsTableColumns.tsx | 4 +-- .../organization/organization_view.test.tsx | 36 ++++++++++++++++++- .../organization/organization_view.tsx | 8 ++--- .../src/components/team/TeamInfo.test.tsx | 28 +++++++++++++++ .../src/components/team/TeamInfo.tsx | 14 ++++---- .../components/team/TeamMemberTab.test.tsx | 34 +++++++++++++++++- .../src/components/team/TeamMemberTab.tsx | 10 +++--- .../components/team/memberFormValues.test.ts | 20 +++++++++-- .../src/components/team/memberFormValues.ts | 6 ++-- 10 files changed, 149 insertions(+), 25 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx index a06c5c885e36..1ac33a271863 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx @@ -165,6 +165,20 @@ describe("OrganizationsTable", () => { expect(screen.getByText("RPM: Unlimited")).toBeInTheDocument(); }); + it("renders a tpm/rpm limit of 0 as 0, never as Unlimited", () => { + render( + , + ); + + expect(screen.getByText("TPM: 0")).toBeInTheDocument(); + expect(screen.getByText("RPM: 0")).toBeInTheDocument(); + expect(screen.queryByText("TPM: Unlimited")).not.toBeInTheDocument(); + expect(screen.queryByText("RPM: Unlimited")).not.toBeInTheDocument(); + }); + it("renders loading skeletons instead of rows while loading", () => { render( - TPM: {tpm_limit ? tpm_limit : "Unlimited"} - RPM: {rpm_limit ? rpm_limit : "Unlimited"} + TPM: {tpm_limit ?? "Unlimited"} + RPM: {rpm_limit ?? "Unlimited"} ); } diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 0337fea41310..b8d8e3ba9c8f 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi, test, expect, beforeEach } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -290,3 +290,37 @@ test("should keep unsaved settings edits when switching tabs and back", async () expect(screen.getByLabelText(/Organization Name/i)).toHaveValue("Renamed Org"); }); + +test("renders a tpm/rpm limit of 0 as 0 in the overview and settings tabs, never as Unlimited", async () => { + const zeroLimitOrg = { + ...mockOrg, + litellm_budget_table: { ...mockOrg.litellm_budget_table, tpm_limit: 0, rpm_limit: 0 }, + }; + mockUseOrganization.mockReturnValue({ data: zeroLimitOrg, isLoading: false } as unknown as ReturnType< + typeof useOrganization + >); + + const user = userEvent.setup(); + renderWithProviders( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={true} + userModels={[]} + editOrg={false} + />, + ); + + const overview = await screen.findByRole("tabpanel", { name: "Overview" }); + expect(within(overview).getByText("TPM: 0")).toBeInTheDocument(); + expect(within(overview).getByText("RPM: 0")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + const settings = await screen.findByRole("tabpanel", { name: "Settings" }); + expect(within(settings).getByText("TPM: 0")).toBeInTheDocument(); + expect(within(settings).getByText("RPM: 0")).toBeInTheDocument(); + expect(screen.queryByText("TPM: Unlimited")).not.toBeInTheDocument(); + expect(screen.queryByText("RPM: Unlimited")).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 2590f3bad52e..7c250a5ef980 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -206,8 +206,8 @@ const OrganizationInfoView: React.FC = ({

Rate Limits

-

TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}

-

RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}

+

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}

)} @@ -311,8 +311,8 @@ const OrganizationInfoView: React.FC = ({

Rate Limits

-
TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}
-
RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}
+
TPM: {orgData.litellm_budget_table.tpm_limit ?? "Unlimited"}
+
RPM: {orgData.litellm_budget_table.rpm_limit ?? "Unlimited"}

Budget

diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index a7e8e6788dda..c3cc362e29e9 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -319,6 +319,34 @@ describe("TeamInfoView", () => { expect(screen.getByText(/of \$1,000\.00/)).toBeInTheDocument(); }); + it("renders a tpm/rpm/budget limit of 0 as 0 in the overview and settings tabs, never as Unlimited or No Limit", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + tpm_limit: 0, + rpm_limit: 0, + team_member_budget_table: { max_budget: 0, budget_duration: null, tpm_limit: 0, rpm_limit: 0 }, + }), + ); + + renderWithProviders(); + + const overview = await screen.findByRole("tabpanel", { name: "Overview" }); + expect(within(overview).getByText("TPM: 0")).toBeInTheDocument(); + expect(within(overview).getByText("RPM: 0")).toBeInTheDocument(); + + await userEvent.setup({ delay: null }).click(screen.getByRole("tab", { name: "Settings" })); + const settings = await screen.findByRole("tabpanel", { name: "Settings" }); + expect(within(settings).getByText("TPM: 0")).toBeInTheDocument(); + expect(within(settings).getByText("RPM: 0")).toBeInTheDocument(); + expect(within(settings).getByText("TPM Limit: 0")).toBeInTheDocument(); + expect(within(settings).getByText("RPM Limit: 0")).toBeInTheDocument(); + expect(within(settings).getByText("Max Budget: 0")).toBeInTheDocument(); + expect(screen.queryByText("TPM: Unlimited")).not.toBeInTheDocument(); + expect(screen.queryByText("RPM: Unlimited")).not.toBeInTheDocument(); + expect(screen.queryByText("TPM Limit: No Limit")).not.toBeInTheDocument(); + expect(screen.queryByText("RPM Limit: No Limit")).not.toBeInTheDocument(); + }); + it("should display guardrails in overview when present", async () => { vi.mocked(networking.teamInfoCall).mockResolvedValue( createMockTeamData({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 727445e4fcd4..88a893fd3e4e 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -971,8 +971,8 @@ const TeamInfoView: React.FC = ({

Rate Limits

-

TPM: {info.tpm_limit || "Unlimited"}

-

RPM: {info.rpm_limit || "Unlimited"}

+

TPM: {info.tpm_limit ?? "Unlimited"}

+

RPM: {info.rpm_limit ?? "Unlimited"}

{info.max_parallel_requests &&

Max Parallel Requests: {info.max_parallel_requests}

} {(() => { const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record; @@ -1760,8 +1760,8 @@ const TeamInfoView: React.FC = ({

Rate Limits

-
TPM: {info.tpm_limit || "Unlimited"}
-
RPM: {info.rpm_limit || "Unlimited"}
+
TPM: {info.tpm_limit ?? "Unlimited"}
+
RPM: {info.rpm_limit ?? "Unlimited"}
{(() => { const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record; const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record; @@ -1811,11 +1811,11 @@ const TeamInfoView: React.FC = ({

-
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
+
Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
-
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
-
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
+
TPM Limit: {info.team_member_budget_table?.tpm_limit ?? "No Limit"}
+
RPM Limit: {info.team_member_budget_table?.rpm_limit ?? "No Limit"}

Router Settings

diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 04234cf5a5e5..08be9abaa8b5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -322,6 +322,38 @@ describe("TeamMembersComponent", () => { expect(mockSetSelectedEditMember).toHaveBeenCalled(); }); + it("keeps a member's stored 0 limits as 0 in the table and in the edit payload, never unlimited", async () => { + const user = userEvent.setup(); + vi.mocked(isProxyAdminRole).mockReturnValue(true); + const teamData = createMockTeamData(); + teamData.team_memberships[0].litellm_budget_table = { + ...teamData.team_memberships[0].litellm_budget_table, + max_budget: 0, + tpm_limit: 0, + rpm_limit: 0, + }; + + renderWithProviders( + , + ); + + const memberRow = screen.getByRole("row", { name: /user1@test\.com/ }); + expect(within(memberRow).getByText("0 RPM / 0 TPM")).toBeInTheDocument(); + expect(within(memberRow).queryByText("No Limits")).not.toBeInTheDocument(); + + await user.click(within(memberRow).getByTestId("edit-member")); + + const zeroLimitsMember = { user_id: "user1@test.com", max_budget_in_team: 0, tpm_limit: 0, rpm_limit: 0 }; + expect(mockSetSelectedEditMember).toHaveBeenCalledWith(expect.objectContaining(zeroLimitsMember)); + }); + it("should call setIsAddMemberModalVisible when Add Member button is clicked", async () => { const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index a662a02f91ef..4d7246eb077f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -71,8 +71,8 @@ export default function TeamMemberTab({ const rpmLimit = membership?.litellm_budget_table?.rpm_limit; const tpmLimit = membership?.litellm_budget_table?.tpm_limit; - const rpmText = rpmLimit ? `${formatNumber(rpmLimit)} RPM` : null; - const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null; + const rpmText = rpmLimit != null ? `${formatNumber(rpmLimit)} RPM` : null; + const tpmText = tpmLimit != null ? `${formatNumber(tpmLimit)} TPM` : null; const limits = [rpmText, tpmText].filter(Boolean); return limits.length > 0 ? limits.join(" / ") : "No Limits"; @@ -191,9 +191,9 @@ export default function TeamMemberTab({ 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, + 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, budget_duration: membership?.litellm_budget_table?.budget_duration || null, allowed_models: membership?.litellm_budget_table?.allowed_models || [], }; diff --git a/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts b/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts index 398719c61979..7a1fcd85a807 100644 --- a/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts +++ b/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts @@ -82,7 +82,7 @@ describe("buildMemberFormValues", () => { }); }); - it("collapses falsy budgets and limits to null and a missing model list to an empty array", () => { + it("keeps a stored budget or limit of 0 as 0 because only null means unlimited", () => { expect( buildMemberFormValues( "edit", @@ -90,6 +90,19 @@ describe("buildMemberFormValues", () => { teamConfig, ), ).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: 0, + budget_duration: null, + tpm_limit: 0, + rpm_limit: 0, + allowed_models: [], + }); + }); + + it("collapses missing budgets and limits to null and a missing model list to an empty array", () => { + const unlimitedMember = { user_email: "a@b.com", user_id: "u1", role: "user", @@ -98,7 +111,10 @@ describe("buildMemberFormValues", () => { tpm_limit: null, rpm_limit: null, allowed_models: [], - }); + }; + expect( + buildMemberFormValues("edit", { user_email: "a@b.com", user_id: "u1", role: "user" }, teamConfig), + ).toStrictEqual(unlimitedMember); }); it("falls back to the configured default role when the member has none", () => { diff --git a/ui/litellm-dashboard/src/components/team/memberFormValues.ts b/ui/litellm-dashboard/src/components/team/memberFormValues.ts index 942b9b665fe9..51b8fac71c12 100644 --- a/ui/litellm-dashboard/src/components/team/memberFormValues.ts +++ b/ui/litellm-dashboard/src/components/team/memberFormValues.ts @@ -43,9 +43,9 @@ export const buildMemberFormValues = ( const seeded: MemberFormValues = { ...initialData, role: (initialData.role as string) || config.defaultRole, - max_budget_in_team: initialData.max_budget_in_team || null, - tpm_limit: initialData.tpm_limit || null, - rpm_limit: initialData.rpm_limit || null, + max_budget_in_team: initialData.max_budget_in_team ?? null, + tpm_limit: initialData.tpm_limit ?? null, + rpm_limit: initialData.rpm_limit ?? null, budget_duration: initialData.budget_duration || null, allowed_models: initialData.allowed_models || [], }; From d124a002e6f0b4dd0f63416431d351f82ee59d61 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 21 Aug 2026 22:10:33 -0700 Subject: [PATCH 2/2] test(ui): assert a stored 0 member limit survives an untouched save The EditMembership integration test named the old 0 -> null collapse as the expected payload, so the related-tests CI job went red once the form kept 0. It now asserts 0 survives and only the empty budget_duration collapses to null. The TeamMemberTab fixture is built with a map instead of mutating the nested membership --- .../team/EditMembership.integration.test.tsx | 8 ++++---- .../src/components/team/TeamMemberTab.test.tsx | 17 +++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx index b1dd3a2efcb0..a82f475512c4 100644 --- a/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx @@ -110,7 +110,7 @@ describe("EditMembership submit payload", () => { expect(submitted()).toStrictEqual(expected); }); - it("collapses falsy budget and limit values to null and a missing model list to an empty array", async () => { + it("keeps stored 0 budget and limits as 0 on an untouched save, collapsing only empty strings and a missing model list", async () => { renderEdit(teamMemberConfig, { user_id: "u1", user_email: "a@b.com", @@ -128,10 +128,10 @@ describe("EditMembership submit payload", () => { user_email: "a@b.com", user_id: "u1", role: "user", - max_budget_in_team: null, + max_budget_in_team: 0, budget_duration: null, - tpm_limit: null, - rpm_limit: null, + tpm_limit: 0, + rpm_limit: 0, allowed_models: [], }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 08be9abaa8b5..6711514bfe90 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -325,12 +325,17 @@ describe("TeamMembersComponent", () => { it("keeps a member's stored 0 limits as 0 in the table and in the edit payload, never unlimited", async () => { const user = userEvent.setup(); vi.mocked(isProxyAdminRole).mockReturnValue(true); - const teamData = createMockTeamData(); - teamData.team_memberships[0].litellm_budget_table = { - ...teamData.team_memberships[0].litellm_budget_table, - max_budget: 0, - tpm_limit: 0, - rpm_limit: 0, + const baseTeamData = createMockTeamData(); + const teamData = { + ...baseTeamData, + team_memberships: baseTeamData.team_memberships.map((membership, index) => + index === 0 + ? { + ...membership, + litellm_budget_table: { ...membership.litellm_budget_table, max_budget: 0, tpm_limit: 0, rpm_limit: 0 }, + } + : membership, + ), }; renderWithProviders(