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; +};