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)) ||