mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
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>
This commit is contained in:
parent
691ca03266
commit
e8f4832f18
4 changed files with 233 additions and 17 deletions
|
|
@ -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(<ModelGroupAliasPanel />);
|
||||
|
||||
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<ReturnType<typeof getRouterSettingsCall>>) => void;
|
||||
const pendingOwnership = new Promise<Awaited<ReturnType<typeof getRouterSettingsCall>>>((resolve) => {
|
||||
resolveOwnership = resolve;
|
||||
});
|
||||
vi.mocked(getRouterSettingsCall).mockReturnValue(pendingOwnership);
|
||||
|
||||
render(<ModelGroupAliasPanel />);
|
||||
|
||||
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(<ModelGroupAliasPanel />);
|
||||
|
||||
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(<ModelGroupAliasPanel />);
|
||||
|
||||
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(<ModelGroupAliasPanel />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"These aliases are defined in config.yaml and are read-only here. Edit the config file to change them.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<ModelGroupAliasSettings accessToken="test-token" initialModelGroupAlias={aliases} managedByConfig />);
|
||||
|
||||
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(<ModelGroupAliasSettings accessToken="test-token" initialModelGroupAlias={aliases} />);
|
||||
|
||||
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" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,8 @@ interface ModelGroupAliasSettingsProps {
|
|||
accessToken: string;
|
||||
initialModelGroupAlias?: Record<string, ModelGroupAliasValue>;
|
||||
onAliasUpdate?: (updatedAlias: { [key: string]: string }) => void;
|
||||
managedByConfig?: boolean;
|
||||
ownershipLoading?: boolean;
|
||||
}
|
||||
|
||||
interface AliasItem {
|
||||
|
|
@ -23,10 +25,13 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
accessToken,
|
||||
initialModelGroupAlias = {},
|
||||
onAliasUpdate,
|
||||
managedByConfig = false,
|
||||
ownershipLoading = false,
|
||||
}) => {
|
||||
const [aliases, setAliases] = useState<AliasItem[]>([]);
|
||||
const [newAlias, setNewAlias] = useState({ aliasName: "", targetModelGroup: "" });
|
||||
const [editingAlias, setEditingAlias] = useState<AliasItem | null>(null);
|
||||
const writesDisabled = managedByConfig || ownershipLoading;
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -73,6 +78,8 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
};
|
||||
|
||||
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<ModelGroupAliasSettingsProps> = ({
|
|||
};
|
||||
|
||||
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<ModelGroupAliasSettingsProps> = ({
|
|||
};
|
||||
|
||||
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<ModelGroupAliasSettingsProps> = ({
|
|||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-6">
|
||||
{managedByConfig && (
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
These aliases are defined in config.yaml and are read-only here. Edit the config file to change them.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground mb-2">Add New Alias</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
|
|
@ -177,6 +194,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
<input
|
||||
type="text"
|
||||
value={newAlias.aliasName}
|
||||
disabled={writesDisabled}
|
||||
onChange={(e) =>
|
||||
setNewAlias({
|
||||
...newAlias,
|
||||
|
|
@ -184,7 +202,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
})
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -192,6 +210,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
<input
|
||||
type="text"
|
||||
value={newAlias.targetModelGroup}
|
||||
disabled={writesDisabled}
|
||||
onChange={(e) =>
|
||||
setNewAlias({
|
||||
...newAlias,
|
||||
|
|
@ -199,14 +218,14 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
})
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleAddAlias}
|
||||
disabled={!newAlias.aliasName || !newAlias.targetModelGroup}
|
||||
className={`flex items-center px-4 py-2 rounded-md text-sm ${!newAlias.aliasName || !newAlias.targetModelGroup ? "bg-border text-muted-foreground cursor-not-allowed" : "bg-success text-success-foreground hover:bg-success/80"}`}
|
||||
disabled={writesDisabled || !newAlias.aliasName || !newAlias.targetModelGroup}
|
||||
className={`flex items-center px-4 py-2 rounded-md text-sm ${writesDisabled || !newAlias.aliasName || !newAlias.targetModelGroup ? "bg-border text-muted-foreground cursor-not-allowed" : "bg-success text-success-foreground hover:bg-success/80"}`}
|
||||
>
|
||||
<PlusCircleIcon className="w-4 h-4 mr-1" />
|
||||
Add Alias
|
||||
|
|
@ -229,7 +248,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
<TableBody>
|
||||
{aliases.map((alias) => (
|
||||
<TableRow key={alias.id} className="h-8">
|
||||
{editingAlias && editingAlias.id === alias.id ? (
|
||||
{editingAlias && !writesDisabled && editingAlias.id === alias.id ? (
|
||||
<>
|
||||
<TableCell className="py-0.5">
|
||||
<input
|
||||
|
|
@ -286,13 +305,17 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditAlias(alias)}
|
||||
className="text-xs bg-info/10 text-info px-2 py-1 rounded-sm hover:bg-info/15"
|
||||
disabled={writesDisabled}
|
||||
aria-label={`Edit alias ${alias.aliasName}`}
|
||||
className={`text-xs px-2 py-1 rounded-sm ${writesDisabled ? "bg-border text-muted-foreground cursor-not-allowed" : "bg-info/10 text-info hover:bg-info/15"}`}
|
||||
>
|
||||
<PencilIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAlias(alias.id)}
|
||||
className="text-xs bg-destructive/10 text-destructive px-2 py-1 rounded-sm hover:bg-destructive/15"
|
||||
disabled={writesDisabled}
|
||||
aria-label={`Delete alias ${alias.aliasName}`}
|
||||
className={`text-xs px-2 py-1 rounded-sm ${writesDisabled ? "bg-border text-muted-foreground cursor-not-allowed" : "bg-destructive/10 text-destructive hover:bg-destructive/15"}`}
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue