From a7daa21df700f2eb5355924b8893b1e86ade1db8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:54:05 +0000 Subject: [PATCH 1/6] fix(ui): let admins change a model's team from the model edit page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/ModelInfoEditForm.tsx | 45 ++++++++++++++++--- .../src/components/model_info_view.test.tsx | 26 +++++++++++ .../src/components/model_info_view.tsx | 7 +++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index fb4d90b5ea2..7fa51d73b32 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -24,6 +24,7 @@ import CacheControlInjectionPoints, { CACHE_CONTROL_TOOLTIP, type CacheControlInjectionPoint, } from "./add_model/cache_control_settings"; +import type { Team } from "./key_team_helpers/key_list"; import type { CredentialItem } from "./networking"; import NumericalInput from "./shared/numerical_input"; import type { Tag } from "./tag_management/types"; @@ -103,6 +104,7 @@ export interface ModelEditFormValues { litellm_credential_name?: string; litellm_extra_params?: string; model_info?: string; + team_id?: string; } type ModelEditFieldName = keyof ModelEditFormValues; @@ -139,6 +141,7 @@ const modelEditShape = { litellm_credential_name: textish, litellm_extra_params: textish, model_info: textish, + team_id: textish, }; const isJson = (value: string): boolean => { @@ -260,6 +263,7 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool null, 2, ), + team_id: localModelData.model_info?.team_id ?? undefined, }); const displayCost = (localModelData: any, field: TouchedPricingField): string => { @@ -286,6 +290,7 @@ interface ModelInfoEditFormProps { tagsList: Record; credentialsList: CredentialItem[]; healthCheckModelOptions: { value: string; label: string }[]; + teams: Team[] | null; } const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -357,6 +362,7 @@ const ModelInfoEditForm: React.FC = ({ tagsList, credentialsList, healthCheckModelOptions, + teams, }) => { // Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change. const touchedRef = React.useRef>(new Set()); @@ -802,11 +808,40 @@ const ModelInfoEditForm: React.FC = ({
Team - - {teamAlias - ? `${teamAlias} (${modelData.model_info.team_id})` - : modelData.model_info.team_id || "Not Set"} - + {isEditing ? ( + + {({ id, value, onChange, onBlur }) => { + const items = (teams ?? []).map((team) => ({ + value: team.team_id, + label: team.team_alias ? `${team.team_alias} (${team.team_id})` : team.team_id, + })); + return ( + + ); + }} + + ) : ( + + {teamAlias + ? `${teamAlias} (${modelData.model_info.team_id})` + : modelData.model_info.team_id || "Not Set"} + + )}
diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index f714b8e5c4a..33f77085551 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1642,6 +1642,32 @@ describe("ModelInfoView", () => { expect(payload.model_info).toMatchObject({ team_id: "team-7" }); }); + it("sends the team picked in the Team ID selector", async () => { + mockUseTeams.mockReturnValue({ + data: [ + { team_id: "team-1", team_alias: "alpha" }, + { team_id: "team-2", team_alias: "beta" }, + ], + isLoading: false, + error: null, + }); + const teamModel = { + ...defaultModelData, + model_info: { ...defaultModelData.model_info, team_id: "team-1" }, + }; + mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] }); + const user = userEvent.setup(); + await enterEditMode(user); + + await user.click(screen.getByText("alpha (team-1)")); + await user.click(await screen.findByText("beta (team-2)")); + + const payload = await save(user); + + expect(payload.model_info.team_id).toBe("team-2"); + }); + it("sends the edited LiteLLM extra params", async () => { const user = userEvent.setup(); await enterEditMode(user); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 8730b7e4322..ecf89bd7cb9 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -376,6 +376,12 @@ export default function ModelInfoView({ health_check_model: values.health_check_model, }; } + if (values.team_id) { + updatedModelInfo = { + ...updatedModelInfo, + team_id: values.team_id, + }; + } updatedModelInfo = applyPtuModelInfo(updatedModelInfo, values, ptuCostAttributionEnabled); } catch (e) { toast.fromError("Invalid JSON in Model Info"); @@ -736,6 +742,7 @@ export default function ModelInfoView({ tagsList={tagsList} credentialsList={credentialsList} healthCheckModelOptions={healthCheckModelOptions} + teams={teams ?? null} /> ) : (

Loading...

From 59693d501904e12e7742ce165e5b19046fb41d20 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:08:39 +0000 Subject: [PATCH 2/6] fix(ui): only offer team admins the teams they administer in the Team ID selector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/model_info_view.test.tsx | 26 +++++++++++++++++++ .../src/components/model_info_view.tsx | 11 +++----- ui/litellm-dashboard/src/utils/roles.ts | 11 ++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 33f77085551..c5a03b0eed1 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1668,6 +1668,32 @@ describe("ModelInfoView", () => { expect(payload.model_info.team_id).toBe("team-2"); }); + it("only offers a team admin the teams they administer", async () => { + mockUseTeams.mockReturnValue({ + data: [ + { team_id: "team-1", team_alias: "alpha", members_with_roles: [{ user_id: "123", role: "admin" }] }, + { team_id: "team-2", team_alias: "beta", members_with_roles: [{ user_id: "123", role: "user" }] }, + { team_id: "team-3", team_alias: "gamma", members_with_roles: [{ user_id: "123", role: "admin" }] }, + ], + isLoading: false, + error: null, + }); + const teamModel = { + ...defaultModelData, + model_info: { ...defaultModelData.model_info, team_id: "team-1" }, + }; + mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] }); + const user = userEvent.setup(); + render(, { wrapper }); + await user.click(await screen.findByRole("button", { name: /edit settings/i })); + + await user.click(await screen.findByText("alpha (team-1)")); + + expect(await screen.findByRole("option", { name: "gamma (team-3)" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "beta (team-2)" })).not.toBeInTheDocument(); + }); + it("sends the edited LiteLLM extra params", async () => { const user = userEvent.setup(); await enterEditMode(user); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index ecf89bd7cb9..fa77b3d5739 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -22,6 +22,7 @@ import { isComplexityRouter as isComplexityRouterParams, } from "./add_model/auto_router_strategies"; import { canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions"; +import { teamsUserCanAssign } from "@/utils/roles"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; @@ -130,6 +131,7 @@ export default function ModelInfoView({ }; const canEditModel = canModifyModel(actor, teams ?? null, origin); const canEditRouter = canEditAutoRouter(actor, teams ?? null, origin); + const assignableTeams = useMemo(() => teamsUserCanAssign(teams ?? null, userRole, userID), [teams, userRole, userID]); // Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router. const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params); // Broader than the editor check: adaptive and quality routers equally have no upstream @@ -376,12 +378,7 @@ export default function ModelInfoView({ health_check_model: values.health_check_model, }; } - if (values.team_id) { - updatedModelInfo = { - ...updatedModelInfo, - team_id: values.team_id, - }; - } + if (values.team_id) updatedModelInfo = { ...updatedModelInfo, team_id: values.team_id }; updatedModelInfo = applyPtuModelInfo(updatedModelInfo, values, ptuCostAttributionEnabled); } catch (e) { toast.fromError("Invalid JSON in Model Info"); @@ -742,7 +739,7 @@ export default function ModelInfoView({ tagsList={tagsList} credentialsList={credentialsList} healthCheckModelOptions={healthCheckModelOptions} - teams={teams ?? null} + teams={assignableTeams} /> ) : (

Loading...

diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 62a5f02cc39..30b53e9b089 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -46,6 +46,17 @@ export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | nul return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin"); }; +export const teamsUserCanAssign = ( + teams: Team[] | null, + userRole: string | null, + userID: string | null, +): Team[] | null => { + if (teams == null || all_admin_roles.includes(userRole ?? "")) { + return teams; + } + return teams.filter((team) => isUserTeamAdminForSingleTeam(team.members_with_roles, userID ?? "")); +}; + export const isOrgAdminForAnyOrg = ( organizations: Organization[] | null | undefined, userID: string | null | undefined, From 500790a93a5104f4d3fd4aeb850efe2bb4f907dc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:19:23 +0000 Subject: [PATCH 3/6] fix(ui): keep every team selectable for org admins in the Team ID selector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/utils/roles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 30b53e9b089..785f6ff78f4 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -51,7 +51,7 @@ export const teamsUserCanAssign = ( userRole: string | null, userID: string | null, ): Team[] | null => { - if (teams == null || all_admin_roles.includes(userRole ?? "")) { + if (teams == null || all_admin_roles.includes(userRole ?? "") || isOrgAdminSessionRole(userRole)) { return teams; } return teams.filter((team) => isUserTeamAdminForSingleTeam(team.members_with_roles, userID ?? "")); From 6db76b5f3959706911779eec4d1a68c13434e257 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:33:38 +0000 Subject: [PATCH 4/6] fix(ui): match the Team ID selector to the backend's proxy-admin-only bypass and show its placeholder Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/ModelInfoEditForm.tsx | 2 +- .../src/components/model_info_view.test.tsx | 16 ++++++++++++++-- ui/litellm-dashboard/src/utils/roles.ts | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index 7fa51d73b32..d76d7c88437 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -818,7 +818,7 @@ const ModelInfoEditForm: React.FC = ({ return ( onChange(selected ?? "")} - > - - - - - {items.map((item) => ( - - {item.label} - - ))} - - - ); - }} + {({ id, value, onChange, onBlur }) => ( + + )} ) : ( diff --git a/ui/litellm-dashboard/src/components/view_model/ModelTeamSelect.tsx b/ui/litellm-dashboard/src/components/view_model/ModelTeamSelect.tsx new file mode 100644 index 00000000000..e00626ea132 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_model/ModelTeamSelect.tsx @@ -0,0 +1,31 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { Team } from "../key_team_helpers/key_list"; + +interface ModelTeamSelectProps { + id: string; + value: string | undefined; + onChange: (teamId: string) => void; + onBlur: () => void; + teams: Team[] | null; +} + +export const ModelTeamSelect: React.FC = ({ id, value, onChange, onBlur, teams }) => { + const items = (teams ?? []).map((team) => ({ + value: team.team_id, + label: team.team_alias ? `${team.team_alias} (${team.team_id})` : team.team_id, + })); + return ( + + ); +}; From 5156dbc67f6825b018ea7d030dce85b687ba95fb Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 17 Sep 2026 17:04:14 -0700 Subject: [PATCH 6/6] fix(ui): show the saved team in read mode before the model query refetches Read mode built the Team display from the react-query snapshot while save only wrote localModelData, so the old team flashed until the parent's invalidation refetched. Read the team from localModelData like the neighbouring fields and look up its alias from the local team id. The regression test also waits for the Select trigger to report expanded before clicking an option, because Base UI opens the popup asynchronously and keeps the closed popup mounted, which made back-to-back option clicks flaky. --- .../src/components/ModelInfoEditForm.tsx | 4 +- .../src/components/model_info_view.test.tsx | 38 +++++++++++++++++-- .../src/components/model_info_view.tsx | 6 ++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index f0186ff4500..70b9f5598a0 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -818,8 +818,8 @@ const ModelInfoEditForm: React.FC = ({ ) : ( {teamAlias - ? `${teamAlias} (${modelData.model_info.team_id})` - : modelData.model_info.team_id || "Not Set"} + ? `${teamAlias} (${localModelData.model_info?.team_id})` + : localModelData.model_info?.team_id || "Not Set"} )} diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 7abbcf91fc6..b86bfb0c62f 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1509,6 +1509,11 @@ describe("ModelInfoView", () => { expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument(); }; + const openSelect = async (user: ReturnType, triggerText: string) => { + await user.click(await screen.findByText(triggerText)); + await screen.findByRole("combobox", { expanded: true }); + }; + const save = async (user: ReturnType) => { await user.click(screen.getByRole("button", { name: /save changes/i })); await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled()); @@ -1660,7 +1665,7 @@ describe("ModelInfoView", () => { const user = userEvent.setup(); await enterEditMode(user); - await user.click(screen.getByText("alpha (team-1)")); + await openSelect(user, "alpha (team-1)"); await user.click(await screen.findByText("beta (team-2)")); const payload = await save(user); @@ -1668,6 +1673,33 @@ describe("ModelInfoView", () => { expect(payload.model_info.team_id).toBe("team-2"); }); + it("shows the picked team in read mode right after saving", async () => { + mockUseTeams.mockReturnValue({ + data: [ + { team_id: "team-1", team_alias: "alpha" }, + { team_id: "team-2", team_alias: "beta" }, + ], + isLoading: false, + error: null, + }); + const teamModel = { + ...defaultModelData, + model_info: { ...defaultModelData.model_info, team_id: "team-1" }, + }; + mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] }); + const user = userEvent.setup(); + await enterEditMode(user); + + await openSelect(user, "alpha (team-1)"); + await user.click(await screen.findByText("beta (team-2)")); + await save(user); + + expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + expect(screen.getByText("beta (team-2)")).toBeInTheDocument(); + expect(screen.queryByText("alpha (team-1)")).not.toBeInTheDocument(); + }); + it("shows the Team ID placeholder for a model with no team", async () => { mockUseTeams.mockReturnValue({ data: [{ team_id: "team-1", team_alias: "alpha" }], @@ -1734,7 +1766,7 @@ describe("ModelInfoView", () => { const user = userEvent.setup(); await enterEditMode(user); - await user.click(await screen.findByText("selected-credential")); + await openSelect(user, "selected-credential"); await user.click(await screen.findByText("other-credential")); const payload = await save(user); @@ -1770,7 +1802,7 @@ describe("ModelInfoView", () => { const user = userEvent.setup(); await enterEditMode(user); - await user.click(screen.getByText("Select existing health check model")); + await openSelect(user, "Select existing health check model"); await user.click(await screen.findByText("openai/gpt-4o")); const payload = await save(user); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index fa77b3d5739..9400c0e6bf9 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -115,7 +115,9 @@ export default function ModelInfoView({ // Keep modelData variable name for backwards compatibility const modelData = transformedModelData; - const teamAlias = teams?.find((team) => team.team_id === modelData?.model_info?.team_id)?.team_alias || null; + const aliasForTeam = (teamId: string | null | undefined): string | null => + teams?.find((team) => team.team_id === teamId)?.team_alias || null; + const teamAlias = aliasForTeam(modelData?.model_info?.team_id); const rawModelInfoEntries = Object.entries(modelData?.model_info ?? {}).flatMap((entry) => entry[0] === "team_id" && teamAlias ? [entry, ["team_alias", teamAlias]] : [entry], ); @@ -724,7 +726,7 @@ export default function ModelInfoView({