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:
yassin 2026-10-10 20:34:57 +00:00
parent 691ca03266
commit e8f4832f18
4 changed files with 233 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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