From 034664001ff7cd4e69da8788b777a5d628b7f98d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 5 Oct 2026 15:01:12 -0700 Subject: [PATCH] fix(ui): send null instead of $0 when a team's member default budget is cleared (#44644) Clearing Default Budget (USD) under Team Member Settings sent Number("") = 0, which turned the shared member default into a $0 cap and blocked every member still on the default. Opening Team Member Settings on a team whose default has no dollar cap did the same through Number(null). Team settings numeric fields now go through one shared numberOrNull helper, which the team admin settings form already used --- .../src/components/team/TeamInfo.test.tsx | 43 +++++++++++++++++-- .../src/components/team/TeamInfo.tsx | 34 ++++++--------- .../components/team/teamAdminEditAccess.ts | 7 +-- .../src/lib/forms/numberOrNull.ts | 5 +++ 4 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 ui/litellm-dashboard/src/lib/forms/numberOrNull.ts diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 4650f4b6987..3a6ab2ffe1a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -2118,6 +2118,43 @@ describe("TeamInfoView - which team member fields reach the update payload depen expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("team_member_budget_duration"); }); + it("sends a null team_member_budget when Default Budget is cleared, instead of a $0 cap", async () => { + const user = userEvent.setup({ delay: null }); + await openEditor(user); + + await user.click(screen.getByText("Team Member Settings")); + fireEvent.change(await screen.findByLabelText("Default Budget (USD)"), { target: { value: "" } }); + const payload = await save(user); + + expect(JSON.parse(JSON.stringify(payload))).toMatchObject({ + team_member_budget: null, + team_member_tpm_limit: 11, + team_member_rpm_limit: 22, + }); + }); + + it("keeps a member default with no dollar cap uncapped when Team Member Settings is saved", async () => { + const user = userEvent.setup({ delay: null }); + await openEditor(user, { max_budget: null, budget_duration: "30d", tpm_limit: null, rpm_limit: 60 }); + + await user.click(screen.getByText("Team Member Settings")); + await screen.findByLabelText("Default Budget (USD)"); + const payload = await save(user); + + expect(JSON.parse(JSON.stringify(payload))).toMatchObject({ team_member_budget: null, team_member_rpm_limit: 60 }); + }); + + it("sends a typed Default Budget as a number", async () => { + const user = userEvent.setup({ delay: null }); + await openEditor(user); + + await user.click(screen.getByText("Team Member Settings")); + fireEvent.change(await screen.findByLabelText("Default Budget (USD)"), { target: { value: "12.5" } }); + const payload = await save(user); + + expect(payload.team_member_budget).toBe(12.5); + }); + it("omits object_permission.search_tools while Search Tool Settings is closed", async () => { const user = userEvent.setup({ delay: null }); await openEditor(user); @@ -2456,7 +2493,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => { expect(networking.teamUpdateCall).not.toHaveBeenCalled(); }); - it("carries every typed value to the update payload at the type and shape antd sends today", async () => { + it("carries every typed value to the update payload, with numeric fields as numbers", async () => { const user = userEvent.setup({ delay: null }); await openEditor(user); @@ -2479,8 +2516,8 @@ describe("TeamInfoView - the exact bytes the update call sends", () => { const payload = await save(user); expect(payload.team_alias).toBe("Renamed Team"); - expect(payload.soft_budget).toBe("9.5"); - expect(payload.tpm_limit).toBe("555"); + expect(payload.soft_budget).toBe(9.5); + expect(payload.tpm_limit).toBe(555); expect((payload.metadata as Record).soft_budget_alerting_emails).toStrictEqual([ "a@test.com", "b@test.com", diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 1128d8ebe1a..4c62423a474 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -19,7 +19,7 @@ import { } from "@/components/networking"; import { useGuardrails, GuardrailListItem } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; +import { numberOrNull } from "@/lib/forms/numberOrNull"; import type { ObjectPermission } from "@/components/object_permission_types"; import { isProxyAdminRole } from "@/utils/roles"; import { ArrowLeftIcon } from "@heroicons/react/outline"; @@ -326,7 +326,7 @@ export interface TeamData { object_permission?: ObjectPermission | null; caller_edit_access?: CallerEditAccess; team_member_budget_table: { - max_budget: number; + max_budget: number | null; budget_duration: string | null; tpm_limit: number | null; rpm_limit: number | null; @@ -980,14 +980,7 @@ const TeamInfoView: React.FC = ({ } } - const sanitizeNumeric = (v: any) => { - if (v === null || v === undefined) return null; - if (typeof v === "string" && v.trim() === "") return null; - if (typeof v === "number" && Number.isNaN(v)) return null; - return v; - }; - - const estimatedOutputTokens = sanitizeNumeric(values.default_estimated_output_tokens); + const estimatedOutputTokens = numberOrNull(values.default_estimated_output_tokens); let estimatedOutputTokensPerModel: Record | undefined; if (typeof values.default_estimated_output_tokens_per_model === "string") { @@ -1037,13 +1030,13 @@ const TeamInfoView: React.FC = ({ team_id: teamId, team_alias: values.team_alias, models: normalizeTeamModelSelection(values.models), - tpm_limit: sanitizeNumeric(values.tpm_limit), - rpm_limit: sanitizeNumeric(values.rpm_limit), - tpd_limit: sanitizeNumeric(values.tpd_limit), + tpm_limit: numberOrNull(values.tpm_limit), + rpm_limit: numberOrNull(values.rpm_limit), + tpd_limit: numberOrNull(values.tpd_limit), model_tpm_limit: modelTpmLimit, model_rpm_limit: modelRpmLimit, - max_budget: values.max_budget, - soft_budget: sanitizeNumeric(values.soft_budget), + max_budget: numberOrNull(values.max_budget), + soft_budget: numberOrNull(values.soft_budget), budget_duration: values.budget_duration ?? null, metadata: { ...parsedMetadata, @@ -1052,7 +1045,7 @@ const TeamInfoView: React.FC = ({ opted_out_global_guardrails: optedOutGlobalGuardrails, ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), disable_global_guardrails: killSwitchOnAtSave, - ...(estimatedOutputTokens !== null ? { default_estimated_output_tokens: Number(estimatedOutputTokens) } : {}), + ...(estimatedOutputTokens !== null ? { default_estimated_output_tokens: estimatedOutputTokens } : {}), ...(estimatedOutputTokensPerModel !== undefined ? { default_estimated_output_tokens_per_model: estimatedOutputTokensPerModel } : {}), @@ -1070,11 +1063,10 @@ const TeamInfoView: React.FC = ({ ...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}), }; - updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); updateData.team_member_budget_duration = values.team_member_budget_duration; const newTeamMemberBudget = - values.team_member_budget !== undefined ? Number(values.team_member_budget) : undefined; + values.team_member_budget !== undefined ? numberOrNull(values.team_member_budget) : undefined; if (newTeamMemberBudget !== undefined) { updateData.team_member_budget = newTeamMemberBudget; } @@ -1084,8 +1076,8 @@ const TeamInfoView: React.FC = ({ } if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { - updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); - updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); + updateData.team_member_tpm_limit = numberOrNull(values.team_member_tpm_limit); + updateData.team_member_rpm_limit = numberOrNull(values.team_member_rpm_limit); } // Handle object_permission updates @@ -1224,7 +1216,7 @@ const TeamInfoView: React.FC = ({ const customBudgetUserIds = customBudgetMemberUserIds(teamData?.team_memberships ?? []); if ( - newTeamMemberBudget !== undefined && + typeof newTeamMemberBudget === "number" && shouldPromptMemberBudgetReset( newTeamMemberBudget, info.team_member_budget_table?.max_budget, diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index 686ad06b121..dc74691d96e 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { numberOrNull } from "@/lib/forms/numberOrNull"; export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields"; @@ -57,12 +58,6 @@ export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null }; -const numberOrNull = (value: string | number | null | undefined): number | null => { - if (value === null || value === undefined || String(value).trim() === "") return null; - const parsed = Number(value); - return Number.isNaN(parsed) ? null : parsed; -}; - export const teamAdminSettingsChanges = ( values: TeamAdminSettingsValues, initialValues: TeamAdminSettingsValues, diff --git a/ui/litellm-dashboard/src/lib/forms/numberOrNull.ts b/ui/litellm-dashboard/src/lib/forms/numberOrNull.ts new file mode 100644 index 00000000000..125cd203e4b --- /dev/null +++ b/ui/litellm-dashboard/src/lib/forms/numberOrNull.ts @@ -0,0 +1,5 @@ +export const numberOrNull = (value: string | number | null | undefined): number | null => { + if (value === null || value === undefined || String(value).trim() === "") return null; + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +};