diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx index fc054e51e5b..ce5d038ad5c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx @@ -1,6 +1,6 @@ import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; import * as networking from "@/components/networking"; -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -817,6 +817,80 @@ describe("TeamInfoView - member budget reset prompt", () => { }); }); +describe("TeamInfoView - settings save preserves models (#43845)", () => { + const props = { + teamId: "123", + onUpdate: vi.fn(), + onClose: vi.fn(), + accessToken: "test-token", + is_team_admin: true, + is_proxy_admin: true, + userModels: ["gpt-4.1", "claude-sonnet-4", "gpt-4o-mini"], + editTeam: false, + premiumUser: false, + }; + + beforeEach(() => { + seedDefaultMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("omits models from /team/update when only max_budget is edited", async () => { + const user = userEvent.setup({ delay: null }); + const teamModels = ["gpt-4.1", "claude-sonnet-4"]; + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ models: teamModels, max_budget: 10 }) as any, + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ + data: { team_id: "123", team_alias: "Test Team", models: teamModels }, + team_id: "123", + }); + + renderWithProviders(); + await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0)); + await user.click(screen.getByRole("tab", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: /edit settings/i })); + + const budgetInput = await screen.findByLabelText("Max Budget (USD)"); + fireEvent.change(budgetInput, { target: { value: "25" } }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled()); + const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1]; + expect(payload).not.toHaveProperty("models"); + expect(Number(payload.max_budget)).toBe(25); + }); + + it("includes models in /team/update when Models is edited", async () => { + const user = userEvent.setup({ delay: null }); + const teamModels = ["gpt-4.1", "claude-sonnet-4"]; + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ models: teamModels, max_budget: 10 }) as any, + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ + data: { team_id: "123", team_alias: "Test Team", models: ["gpt-4.1"] }, + team_id: "123", + }); + + renderWithProviders(); + await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0)); + await user.click(screen.getByRole("tab", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: /edit settings/i })); + + await user.click(within(screen.getByLabelText("claude-sonnet-4")).getByRole("button")); + expect(screen.queryByLabelText("claude-sonnet-4")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled()); + const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1]; + expect(payload.models).toEqual(["gpt-4.1"]); + }); +}); + describe("TeamInfoView - model rate limit picker", () => { const props = { teamId: "123", diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index e4bf67ab72f..46081ae96f5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -2421,7 +2421,6 @@ describe("TeamInfoView - the exact bytes the update call sends", () => { const alwaysSent = { team_id: "123", team_alias: "Test Team", - models: ["gpt-4"], tpm_limit: 1000, rpm_limit: 1000, tpd_limit: null, diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 0d63f5d0833..49ea735dcfc 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -73,7 +73,7 @@ import { import { modelMaxBudgetUpdate, StoredModelMaxBudget } from "../key_team_helpers/modelMaxBudgetPayload"; import { computeTeamModelBadges, - normalizeTeamModelSelection, + modelsFieldForTeamUpdate, TeamAccessGroupModelGrant, teamPerModelLimitOptions, TeamModelBadge, @@ -719,7 +719,8 @@ const TeamInfoView: React.FC = ({ return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key))); }; - const onTeamUpdateSubmit = (values: TeamUpdateFormValues) => handleTeamUpdate(mountedUpdateValues(values)); + const onTeamUpdateSubmit = (values: TeamUpdateFormValues) => + handleTeamUpdate(mountedUpdateValues(values), form.formState.dirtyFields); const fetchTeamInfo = async () => { try { @@ -986,7 +987,7 @@ const TeamInfoView: React.FC = ({ } }; - const handleTeamUpdate = async (values: any) => { + const handleTeamUpdate = async (values: any, dirtyFields: Record = {}) => { try { if (!accessToken) return; setIsTeamSaving(true); @@ -1060,10 +1061,12 @@ const TeamInfoView: React.FC = ({ ? { require_trace_id: values.require_trace_id === true } : storedRequireTraceIdMetadata; + // /team/update uses exclude_unset: omitted fields are preserved. Always + // sending models (often []) wipes direct grants on an unrelated save (#43845). const updateData: any = { team_id: teamId, team_alias: values.team_alias, - models: normalizeTeamModelSelection(values.models), + ...modelsFieldForTeamUpdate(dirtyFields.models, values.models), tpm_limit: numberOrNull(values.tpm_limit), rpm_limit: numberOrNull(values.rpm_limit), tpd_limit: numberOrNull(values.tpd_limit), diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts index 5ca42a9391b..310a0cc6527 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeTeamModelBadges, + modelsFieldForTeamUpdate, normalizeTeamModelSelection, TeamAccessGroupModelGrant, teamPerModelLimitOptions, @@ -90,6 +91,22 @@ describe("computeTeamModelBadges", () => { }); }); +describe("modelsFieldForTeamUpdate", () => { + it("omits models when the field is not dirty", () => { + expect(modelsFieldForTeamUpdate(undefined, ["gpt-4.1"])).toEqual({}); + expect(modelsFieldForTeamUpdate(false, ["gpt-4.1"])).toEqual({}); + expect(modelsFieldForTeamUpdate([false, false], ["gpt-4.1"])).toEqual({}); + }); + + it("includes normalized models when the field is dirty", () => { + expect(modelsFieldForTeamUpdate(true, ["gpt-4.1"])).toEqual({ models: ["gpt-4.1"] }); + expect(modelsFieldForTeamUpdate(true, [])).toEqual({ models: ["no-default-models"] }); + expect(modelsFieldForTeamUpdate([false, true], ["claude-sonnet-4"])).toEqual({ + models: ["claude-sonnet-4"], + }); + }); +}); + describe("teamPerModelLimitOptions", () => { const USER_MODELS = ["gpt-4o", "haiku", "openai/gpt-4o-mini", "openai/gpt-4.1"]; diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts index 3027fbb8686..93d6cf68c50 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts @@ -103,3 +103,19 @@ export function computeTeamModelBadges( ), ]; } + +export function modelsFieldForTeamUpdate( + dirtyModels: unknown, + models: string[] | undefined, +): { models: string[] } | Record { + const isDirty = + dirtyModels === true || + (Array.isArray(dirtyModels) && dirtyModels.some(Boolean)) || + (dirtyModels !== null && + typeof dirtyModels === "object" && + Object.values(dirtyModels as Record).some(Boolean)); + if (!isDirty) { + return {}; + } + return { models: normalizeTeamModelSelection(models) }; +}