From e8f4832f18ee2cfbeb57debd5ef2edc972dbf516 Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 10 Oct 2026 20:34:57 +0000 Subject: [PATCH] fix(ui): lock config-owned model group aliases on the alias tab Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ModelGroupAliasPanel.integration.test.tsx | 94 +++++++++++++++++++ .../panels/ModelGroupAliasPanel.tsx | 53 +++++++++-- .../model_group_alias_settings.test.tsx | 66 +++++++++++++ .../components/model_group_alias_settings.tsx | 37 ++++++-- 4 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_group_alias_settings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.integration.test.tsx new file mode 100644 index 00000000000..227a34335a3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.integration.test.tsx @@ -0,0 +1,94 @@ +/* @vitest-environment jsdom */ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCallbacksCall, getRouterSettingsCall } from "@/components/networking"; +import ModelGroupAliasPanel from "./ModelGroupAliasPanel"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "test-token", userId: "user-1", userRole: "Admin" }), +})); + +vi.mock("@/components/networking", () => ({ + getCallbacksCall: vi.fn(), + getRouterSettingsCall: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +const aliases = { + "fast-model": "claude-haiku", + "smart-model": { model: "claude-sonnet", hidden: true }, +}; + +describe("ModelGroupAliasPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCallbacksCall).mockResolvedValue({ router_settings: { model_group_alias: aliases } }); + vi.mocked(getRouterSettingsCall).mockResolvedValue({ source: { model_group_alias: "db" } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("locks delete controls when aliases are config-owned", async () => { + vi.mocked(getRouterSettingsCall).mockResolvedValue({ source: { model_group_alias: "config" } }); + + render(); + + expect( + await screen.findByText( + "These aliases are defined in config.yaml and are read-only here. Edit the config file to change them.", + ), + ).toBeInTheDocument(); + expect(await screen.findByText("fast-model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Delete alias smart-model" })).toBeDisabled(); + }); + + it("keeps aliases locked while ownership is loading", async () => { + let resolveOwnership!: (value: Awaited>) => void; + const pendingOwnership = new Promise>>((resolve) => { + resolveOwnership = resolve; + }); + vi.mocked(getRouterSettingsCall).mockReturnValue(pendingOwnership); + + render(); + + expect(await screen.findByText("fast-model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeDisabled(); + + resolveOwnership({ source: { model_group_alias: "db" } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeEnabled()); + }); + + it("leaves delete controls enabled when aliases are database-managed", async () => { + render(); + + await waitFor(() => expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeEnabled()); + expect(screen.getByRole("button", { name: "Delete alias smart-model" })).toBeEnabled(); + }); + + it("renders aliases with enabled controls when router settings cannot be fetched", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(getRouterSettingsCall).mockRejectedValue(new Error("router settings unavailable")); + + render(); + + expect(await screen.findByText("fast-model")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeEnabled()); + }); + + it("still shows the config-owned notice when callbacks cannot be fetched", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(getCallbacksCall).mockRejectedValue(new Error("callbacks unavailable")); + vi.mocked(getRouterSettingsCall).mockResolvedValue({ source: { model_group_alias: "config" } }); + + render(); + + expect( + await screen.findByText( + "These aliases are defined in config.yaml and are read-only here. Edit the config file to change them.", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.tsx index ec808984c34..025923e08e9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel.tsx @@ -2,28 +2,59 @@ import { useEffect, useState } from "react"; import ModelGroupAliasSettings from "@/components/model_group_alias_settings"; -import { getCallbacksCall } from "@/components/networking"; +import { getCallbacksCall, getRouterSettingsCall } from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function ModelGroupAliasPanel() { const { accessToken, userId: userID, userRole } = useAuthorized(); const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({}); + const [aliasOwnership, setAliasOwnership] = useState<{ + accessToken: string; + userID: typeof userID; + userRole: typeof userRole; + managedByConfig: boolean; + } | null>(null); + const currentAliasOwnership = + aliasOwnership?.accessToken === accessToken && + aliasOwnership?.userID === userID && + aliasOwnership?.userRole === userRole + ? aliasOwnership + : null; + const managedByConfig = currentAliasOwnership?.managedByConfig ?? false; + const ownershipLoading = Boolean(accessToken && !currentAliasOwnership); useEffect(() => { - if (!accessToken || !userID || !userRole) { + if (!accessToken) { return; } let active = true; - void (async () => { - try { - const info = await getCallbacksCall(accessToken, userID, userRole); + const updateAliasOwnership = (managedByConfig: boolean) => { + const ownership = { accessToken, userID, userRole, managedByConfig }; + setAliasOwnership(ownership); + }; + if (userID && userRole) { + void getCallbacksCall(accessToken, userID, userRole) + .then((info) => { + if (active) { + setModelGroupAlias(info.router_settings?.model_group_alias || {}); + } + }) + .catch((error) => { + console.error("Error fetching model group alias:", error); + }); + } + void getRouterSettingsCall(accessToken) + .then((data) => { if (active) { - setModelGroupAlias(info.router_settings?.model_group_alias || {}); + updateAliasOwnership(data?.source?.model_group_alias === "config"); } - } catch (error) { - console.error("Error fetching model group alias:", error); - } - })(); + }) + .catch((error) => { + if (active) { + updateAliasOwnership(false); + } + console.error("Error fetching router settings:", error); + }); return () => { active = false; }; @@ -34,6 +65,8 @@ export default function ModelGroupAliasPanel() { accessToken={accessToken} initialModelGroupAlias={modelGroupAlias} onAliasUpdate={setModelGroupAlias} + managedByConfig={managedByConfig} + ownershipLoading={ownershipLoading} /> ); } diff --git a/ui/litellm-dashboard/src/components/model_group_alias_settings.test.tsx b/ui/litellm-dashboard/src/components/model_group_alias_settings.test.tsx new file mode 100644 index 00000000000..fe59e6eb139 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_group_alias_settings.test.tsx @@ -0,0 +1,66 @@ +/* @vitest-environment jsdom */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCallbacksCall } from "@/components/networking"; +import ModelGroupAliasSettings from "./model_group_alias_settings"; + +vi.mock("@/components/networking", () => ({ + setCallbacksCall: vi.fn(), +})); + +const aliases = { + "fast-model": "claude-haiku", + "smart-model": { model: "claude-sonnet", hidden: true }, +}; + +describe("ModelGroupAliasSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(setCallbacksCall).mockResolvedValue({}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("locks config-owned aliases while keeping their names and targets visible", async () => { + render(); + + expect( + screen.getByText( + "These aliases are defined in config.yaml and are read-only here. Edit the config file to change them.", + ), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., gpt-4o")).toBeDisabled(); + expect(screen.getByPlaceholderText("e.g., gpt-4o-mini-openai")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add Alias" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Edit alias fast-model" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Edit alias smart-model" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Delete alias smart-model" })).toBeDisabled(); + + expect(await screen.findByText("fast-model")).toBeInTheDocument(); + expect(screen.getByText("claude-haiku")).toBeInTheDocument(); + expect(screen.getByText("smart-model")).toBeInTheDocument(); + expect(screen.getByText("claude-sonnet")).toBeInTheDocument(); + + await userEvent.setup().click(screen.getByRole("button", { name: "Delete alias fast-model" })); + expect(setCallbacksCall).not.toHaveBeenCalled(); + }); + + it("keeps aliases editable by default and saves the current string representation after delete", async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("fast-model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit alias fast-model" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Delete alias fast-model" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Delete alias fast-model" })); + + expect(setCallbacksCall).toHaveBeenCalledWith("test-token", { + router_settings: { model_group_alias: { "smart-model": "claude-sonnet" } }, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_group_alias_settings.tsx b/ui/litellm-dashboard/src/components/model_group_alias_settings.tsx index 1e943da25c5..d3cebc76e98 100644 --- a/ui/litellm-dashboard/src/components/model_group_alias_settings.tsx +++ b/ui/litellm-dashboard/src/components/model_group_alias_settings.tsx @@ -11,6 +11,8 @@ interface ModelGroupAliasSettingsProps { accessToken: string; initialModelGroupAlias?: Record; onAliasUpdate?: (updatedAlias: { [key: string]: string }) => void; + managedByConfig?: boolean; + ownershipLoading?: boolean; } interface AliasItem { @@ -23,10 +25,13 @@ const ModelGroupAliasSettings: React.FC = ({ accessToken, initialModelGroupAlias = {}, onAliasUpdate, + managedByConfig = false, + ownershipLoading = false, }) => { const [aliases, setAliases] = useState([]); const [newAlias, setNewAlias] = useState({ aliasName: "", targetModelGroup: "" }); const [editingAlias, setEditingAlias] = useState(null); + const writesDisabled = managedByConfig || ownershipLoading; const [isExpanded, setIsExpanded] = useState(true); useEffect(() => { @@ -73,6 +78,8 @@ const ModelGroupAliasSettings: React.FC = ({ }; const handleAddAlias = async () => { + if (writesDisabled) return; + if (!newAlias.aliasName || !newAlias.targetModelGroup) { toast.fromError("Please provide both alias name and target model group"); return; @@ -100,10 +107,13 @@ const ModelGroupAliasSettings: React.FC = ({ }; const handleEditAlias = (alias: AliasItem) => { + if (writesDisabled) return; + setEditingAlias({ ...alias }); }; const handleUpdateAlias = async () => { + if (writesDisabled) return; if (!editingAlias) return; if (!editingAlias.aliasName || !editingAlias.targetModelGroup) { @@ -131,6 +141,8 @@ const ModelGroupAliasSettings: React.FC = ({ }; const deleteAlias = async (aliasId: string) => { + if (writesDisabled) return; + const updatedAliases = aliases.filter((alias) => alias.id !== aliasId); if (await saveAliasesToBackend(updatedAliases)) { @@ -170,6 +182,11 @@ const ModelGroupAliasSettings: React.FC = ({ {isExpanded && (
+ {managedByConfig && ( +

+ These aliases are defined in config.yaml and are read-only here. Edit the config file to change them. +

+ )}

Add New Alias

@@ -177,6 +194,7 @@ const ModelGroupAliasSettings: React.FC = ({ setNewAlias({ ...newAlias, @@ -184,7 +202,7 @@ const ModelGroupAliasSettings: React.FC = ({ }) } placeholder="e.g., gpt-4o" - className="w-full px-3 py-2 border border-border rounded-md text-sm" + className="w-full px-3 py-2 border border-border rounded-md text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed" />
@@ -192,6 +210,7 @@ const ModelGroupAliasSettings: React.FC = ({ setNewAlias({ ...newAlias, @@ -199,14 +218,14 @@ const ModelGroupAliasSettings: React.FC = ({ }) } placeholder="e.g., gpt-4o-mini-openai" - className="w-full px-3 py-2 border border-border rounded-md text-sm" + className="w-full px-3 py-2 border border-border rounded-md text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed" />