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