This commit is contained in:
GokayAI 2026-10-22 02:12:58 +00:00 • committed by GitHub
commit 074e49be4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 115 additions and 6 deletions

View file

@ -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",

View file

@ -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,

View file

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

View file

@ -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"];

View file

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