mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
Merge f94d352a82 into f2118fc767
This commit is contained in:
commit
074e49be4f
5 changed files with 115 additions and 6 deletions
|
|
@ -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(<TeamInfoView {...props} />);
|
||||
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(<TeamInfoView {...props} />);
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TeamInfoProps> = ({
|
|||
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<TeamInfoProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleTeamUpdate = async (values: any) => {
|
||||
const handleTeamUpdate = async (values: any, dirtyFields: Record<string, unknown> = {}) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
setIsTeamSaving(true);
|
||||
|
|
@ -1060,10 +1061,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
? { 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),
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -103,3 +103,19 @@ export function computeTeamModelBadges(
|
|||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function modelsFieldForTeamUpdate(
|
||||
dirtyModels: unknown,
|
||||
models: string[] | undefined,
|
||||
): { models: string[] } | Record<string, never> {
|
||||
const isDirty =
|
||||
dirtyModels === true ||
|
||||
(Array.isArray(dirtyModels) && dirtyModels.some(Boolean)) ||
|
||||
(dirtyModels !== null &&
|
||||
typeof dirtyModels === "object" &&
|
||||
Object.values(dirtyModels as Record<string, unknown>).some(Boolean));
|
||||
if (!isDirty) {
|
||||
return {};
|
||||
}
|
||||
return { models: normalizeTeamModelSelection(models) };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue