mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
fix(ui): preserve team model grants on unrelated settings saves (#49258)
This commit is contained in:
parent
372c096388
commit
17d1f58336
5 changed files with 97 additions and 6 deletions
|
|
@ -775,6 +775,39 @@ describe("ModelSelect", () => {
|
|||
expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ value: "all-proxy-models", label: "All Proxy Models", includeSpecialOptions: true },
|
||||
{ value: "no-default-models", label: "No Default Models", includeSpecialOptions: false },
|
||||
])(
|
||||
"should label retained $value without offering it again after removal",
|
||||
async ({ value, label, includeSpecialOptions }) => {
|
||||
const user = userEvent.setup();
|
||||
mockUseOrganization.mockReturnValue({
|
||||
data: createMockOrganization(["gpt-4"]),
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useOrganization>);
|
||||
const props = {
|
||||
onChange: mockOnChange,
|
||||
context: "team" as const,
|
||||
organizationID: "org-1",
|
||||
options: { includeSpecialOptions },
|
||||
};
|
||||
const { rerender } = renderWithProviders(<ModelSelect {...props} value={[value]} />);
|
||||
|
||||
expect(screen.getByLabelText(label)).toBeInTheDocument();
|
||||
await openModelList(user);
|
||||
const retainedGroup = within(screen.getByRole("listbox")).getAllByRole("group")[0];
|
||||
expect(within(retainedGroup).getByText("Unavailable")).toBeInTheDocument();
|
||||
await user.click(within(retainedGroup).getByRole("option", { name: label }));
|
||||
expect(mockOnChange).toHaveBeenCalledWith([]);
|
||||
|
||||
rerender(<ModelSelect {...props} value={[]} />);
|
||||
expectNotOffered(label);
|
||||
expectNotOffered("claude-3");
|
||||
expectOffered("gpt-4");
|
||||
},
|
||||
);
|
||||
|
||||
it("should keep the other selections when removing one unavailable model alongside a special option", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
|
|||
|
|
@ -240,7 +240,10 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
|||
];
|
||||
|
||||
const offeredByValue = new Map(offeredGroups.flatMap((group) => group.items).map((option) => [option.value, option]));
|
||||
const selectedOptions = value.map((v) => offeredByValue.get(v) ?? { label: v, value: v });
|
||||
const selectedOptions = value.map(
|
||||
(v) =>
|
||||
offeredByValue.get(v) ?? MODEL_SENTINEL_OPTIONS.find((option) => option.value === v) ?? { label: v, value: v },
|
||||
);
|
||||
const groups: ModelOptionGroup[] = [
|
||||
...unavailableGroups(
|
||||
selectedOptions,
|
||||
|
|
|
|||
|
|
@ -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,7 +817,7 @@ describe("TeamInfoView - member budget reset prompt", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("TeamInfoView - model rate limit picker", () => {
|
||||
describe("TeamInfoView - model settings", () => {
|
||||
const props = {
|
||||
teamId: "123",
|
||||
onUpdate: vi.fn(),
|
||||
|
|
@ -836,6 +836,61 @@ describe("TeamInfoView - model rate limit picker", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "unrestricted", models: [], organizationId: null },
|
||||
{ name: "explicit all models", models: ["all-proxy-models"], organizationId: null },
|
||||
{ name: "access groups only", models: ["no-default-models"], organizationId: null },
|
||||
{ name: "retained explicit models", models: ["retired-model", "openai/*"], organizationId: null },
|
||||
{ name: "unrestricted within an organization", models: [], organizationId: "org-1" },
|
||||
])("preserves $name model grants when saving only a budget", async ({ models, organizationId }) => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ models, organization_id: organizationId }),
|
||||
);
|
||||
mockUseTeam.mockReturnValue({
|
||||
data: { team_id: "123", organization_models: organizationId ? ["gpt-4"] : undefined },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useTeam>);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} userModels={["gpt-4"]} />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
if (models.length === 0) {
|
||||
expect(await screen.findByLabelText("All Proxy Models")).toBeInTheDocument();
|
||||
}
|
||||
fireEvent.change(await screen.findByLabelText("Max Budget (USD)"), { target: { value: "100" } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
|
||||
expect(payload).toMatchObject({ team_id: "123", max_budget: 100 });
|
||||
expect(payload).not.toHaveProperty("models");
|
||||
});
|
||||
|
||||
it("discards a cancelled model clear but saves an intentional clear of unrestricted access", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: [] }));
|
||||
|
||||
renderWithProviders(<TeamInfoView {...props} userModels={["gpt-4"]} />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await user.click(within(await screen.findByLabelText("All Proxy Models")).getByRole("button"));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByLabelText("All Proxy Models")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "100" } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).not.toHaveProperty("models");
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await user.click(within(await screen.findByLabelText("All Proxy Models")).getByRole("button"));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[1][1].models).toEqual(["no-default-models"]);
|
||||
});
|
||||
|
||||
it("offers access-group models, not no-default-models, in the model rate limit picker", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
} from "../key_team_helpers/ModelMaxBudgetEditor";
|
||||
import { modelMaxBudgetUpdate, StoredModelMaxBudget } from "../key_team_helpers/modelMaxBudgetPayload";
|
||||
import {
|
||||
ALL_PROXY_MODELS,
|
||||
computeTeamModelBadges,
|
||||
normalizeTeamModelSelection,
|
||||
TeamAccessGroupModelGrant,
|
||||
|
|
@ -515,7 +516,7 @@ const computeEffectiveGuardrails = (info: TeamInfoRecord, globalGuardrailNames:
|
|||
|
||||
const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]): TeamUpdateFormValues => ({
|
||||
team_alias: info.team_alias,
|
||||
models: info.models,
|
||||
models: info.models.length === 0 ? [ALL_PROXY_MODELS] : info.models,
|
||||
max_budget: info.max_budget,
|
||||
soft_budget: info.soft_budget,
|
||||
soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
|
||||
|
|
@ -1064,7 +1065,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const updateData: any = {
|
||||
team_id: teamId,
|
||||
team_alias: values.team_alias,
|
||||
models: normalizeTeamModelSelection(values.models),
|
||||
...(form.getFieldState("models").isDirty ? { models: normalizeTeamModelSelection(values.models) } : {}),
|
||||
tpm_limit: numberOrNull(values.tpm_limit),
|
||||
rpm_limit: numberOrNull(values.rpm_limit),
|
||||
tpd_limit: numberOrNull(values.tpd_limit),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue