From 006083f45a1556d49657c6c895919d5d085f771e Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:28:27 +0300 Subject: [PATCH 1/2] fix(ui): omit undirtied models on team settings save Saving an unrelated team Settings field always re-posted models, so a stale/empty selection could wipe direct model grants. Match /team/update's exclude_unset contract: only send models when that field is dirty. Fixes #43845 Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../team/TeamInfo.integration.test.tsx | 48 +++++++++++++++++++ .../src/components/team/TeamInfo.tsx | 10 ++-- .../components/team/teamModelAccess.test.ts | 19 +++++++- .../src/components/team/teamModelAccess.ts | 19 ++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) 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 456dc91b13f..6edd0a18067 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx @@ -721,3 +721,51 @@ describe("TeamInfoView - member budget reset prompt", () => { expect(bulkUpdatePOST).not.toHaveBeenCalled(); }); }); + +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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 5dfcf1d8e35..d94d29eda05 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -70,6 +70,7 @@ import { import { modelMaxBudgetUpdate, StoredModelMaxBudget } from "../key_team_helpers/modelMaxBudgetPayload"; import { computeTeamModelBadges, + modelsFieldForTeamUpdate, normalizeTeamModelSelection, TeamAccessGroupModelGrant, TeamModelBadge, @@ -699,7 +700,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 { @@ -960,7 +962,7 @@ const TeamInfoView: React.FC = ({ } }; - const handleTeamUpdate = async (values: any) => { + const handleTeamUpdate = async (values: any, dirtyFields: Record = {}) => { try { if (!accessToken) return; setIsTeamSaving(true); @@ -1033,10 +1035,12 @@ const TeamInfoView: React.FC = ({ ? { [TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]: memberBudgetAlertEmails } : {}; + // /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: sanitizeNumeric(values.tpm_limit), rpm_limit: sanitizeNumeric(values.rpm_limit), tpd_limit: sanitizeNumeric(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 3f0b4e20407..8b8cff6eeb1 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeTeamModelBadges, normalizeTeamModelSelection, TeamAccessGroupModelGrant } from "./teamModelAccess"; +import { computeTeamModelBadges, modelsFieldForTeamUpdate, normalizeTeamModelSelection, TeamAccessGroupModelGrant } from "./teamModelAccess"; const GRANTS: TeamAccessGroupModelGrant[] = [ { access_group_id: "ag-1", access_group_name: "shared", models: ["haiku", "gpt-4o-mini"] }, @@ -84,3 +84,20 @@ 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"], + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts index bc8022592bd..800d0e0cedd 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts @@ -82,3 +82,22 @@ export function computeTeamModelBadges( ), ]; } + + +/** Include models in /team/update only when the Settings form marked them dirty. */ +export function modelsFieldForTeamUpdate( + dirtyModels: unknown, + models: string[] | undefined, +): { models: string[] } | Record { + // Imported lazily-shaped to keep this module free of react-hook-form types. + 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) }; +} From 2f6ad4995a8a324d41f2483cb12f988e6af07720 Mon Sep 17 00:00:00 2001 From: GokayAI <60583610+gokay-ai@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:17:07 +0300 Subject: [PATCH 2/2] fix(ui): align team settings tests with undirtied models omit Drop unused normalizeTeamModelSelection import, remove narrating comments, update exact-bytes expectations, and cover intentional model edits. Prettier on teamModelAccess helpers. Signed-off-by: GokayAI <60583610+gokay-ai@users.noreply.github.com> --- .../team/TeamInfo.integration.test.tsx | 28 ++++++++++++++++++- .../src/components/team/TeamInfo.test.tsx | 1 - .../src/components/team/TeamInfo.tsx | 1 - .../components/team/teamModelAccess.test.ts | 8 ++++-- .../src/components/team/teamModelAccess.ts | 3 -- 5 files changed, 33 insertions(+), 8 deletions(-) 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 6edd0a18067..e17617f0f59 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"; @@ -768,4 +768,30 @@ describe("TeamInfoView - settings save preserves models (#43845)", () => { 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"]); + }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 4650f4b6987..e82d3d50747 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -2191,7 +2191,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 d94d29eda05..dbc1897c4e8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -71,7 +71,6 @@ import { modelMaxBudgetUpdate, StoredModelMaxBudget } from "../key_team_helpers/ import { computeTeamModelBadges, modelsFieldForTeamUpdate, - normalizeTeamModelSelection, TeamAccessGroupModelGrant, TeamModelBadge, TeamModelBadgeKind, diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts index 8b8cff6eeb1..ae43ad0a923 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { computeTeamModelBadges, modelsFieldForTeamUpdate, normalizeTeamModelSelection, TeamAccessGroupModelGrant } from "./teamModelAccess"; +import { + computeTeamModelBadges, + modelsFieldForTeamUpdate, + normalizeTeamModelSelection, + TeamAccessGroupModelGrant, +} from "./teamModelAccess"; const GRANTS: TeamAccessGroupModelGrant[] = [ { access_group_id: "ag-1", access_group_name: "shared", models: ["haiku", "gpt-4o-mini"] }, @@ -85,7 +90,6 @@ describe("computeTeamModelBadges", () => { }); }); - describe("modelsFieldForTeamUpdate", () => { it("omits models when the field is not dirty", () => { expect(modelsFieldForTeamUpdate(undefined, ["gpt-4.1"])).toEqual({}); diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts index 800d0e0cedd..f236e6e2012 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts @@ -83,13 +83,10 @@ export function computeTeamModelBadges( ]; } - -/** Include models in /team/update only when the Settings form marked them dirty. */ export function modelsFieldForTeamUpdate( dirtyModels: unknown, models: string[] | undefined, ): { models: string[] } | Record { - // Imported lazily-shaped to keep this module free of react-hook-form types. const isDirty = dirtyModels === true || (Array.isArray(dirtyModels) && dirtyModels.some(Boolean)) ||