From 1b5f40b47d35a24f731bdf6966f6b2c32dc62994 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:35:26 -0700 Subject: [PATCH 1/3] refactor(ui): move the model alias manager onto design tokens and shadcn controls The shared alias editor was light-only: 18 hardcoded palette classes across its headings, table cells, config preview and five raw controls, so it read grey-on-grey against the dark dashboard theme. Its five buttons also carried no type attribute, and all three consumers render it inside a real antd Form with an onFinish. Measured against the pre-change file in jsdom with a liveness gate on both sides: clicking Add Alias fired the parent's onFinish once, and a row action fired it once more. So editing a model alias inside the create-team, team-update or create-key form also submitted that form. shadcn Button renders type="button", which closes the path. Swapping the raw input and button elements for the shadcn primitives is what makes the colours resolve, since a bare element needs the whole token set hand-written to work in both themes. --- .../ModelAliasManager.test.tsx | 129 ++++++++++++++++++ .../common_components/ModelAliasManager.tsx | 84 ++++++------ 2 files changed, 171 insertions(+), 42 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/ModelAliasManager.test.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.test.tsx new file mode 100644 index 00000000000..ea66578c4af --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.test.tsx @@ -0,0 +1,129 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import ModelAliasManager from "./ModelAliasManager"; + +vi.mock("./ModelSelector", () => ({ + default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => ( + onChange?.(event.target.value)} /> + ), +})); + +vi.mock("@/lib/toast", () => ({ + toast: { success: vi.fn(), fromError: vi.fn(), info: vi.fn() }, +})); + +const SOURCE_PATH = resolve(process.cwd(), "src/components/common_components/ModelAliasManager.tsx"); +const HARDCODED_PALETTE = + /\b(?:text|bg|border|hover:bg|hover:text|dark:bg|dark:text)-(?:gray|slate|zinc|neutral|stone|red|blue|green|yellow|amber|orange)-\d+\b/g; +const SEMANTIC_TOKEN = + /\b(?:text|bg|border)-(?:foreground|muted-foreground|muted|background|card|primary|secondary|destructive|border|input|accent)\b/g; + +const NO_ALIASES: Record = {}; + +const renderInForm = (onSubmit = vi.fn(), onAliasUpdate = vi.fn()) => { + render( +
{ + event.preventDefault(); + onSubmit(); + }} + > + + + , + ); + return { onSubmit, onAliasUpdate, user: userEvent.setup() }; +}; + +const addAlias = async (user: ReturnType, name: string, model: string) => { + await user.type(screen.getByLabelText("Alias Name"), name); + await user.type(screen.getByLabelText("target model"), model); + await user.click(screen.getByRole("button", { name: /add alias/i })); +}; + +describe("ModelAliasManager dark-mode tokens", () => { + const source = readFileSync(SOURCE_PATH, "utf8"); + + it("reads the component source it is asserting about", () => { + expect(source.length).toBeGreaterThan(0); + expect(source).toContain("Manage Existing Aliases"); + }); + + it("uses semantic colour tokens and no hardcoded palette classes", () => { + expect(source.match(SEMANTIC_TOKEN) ?? []).not.toHaveLength(0); + expect(source.match(HARDCODED_PALETTE) ?? []).toHaveLength(0); + }); +}); + +describe("ModelAliasManager", () => { + it("reports a typed alias and target model to the parent", async () => { + const { onAliasUpdate, user } = renderInForm(); + + await addAlias(user, "fast-model", "gpt-4o"); + + expect(onAliasUpdate).toHaveBeenCalledWith({ "fast-model": "gpt-4o" }); + expect(screen.getByText("fast-model")).toBeInTheDocument(); + }); + + it("keeps Add Alias disabled until both fields have a value", async () => { + const { onAliasUpdate, user } = renderInForm(); + const addButton = screen.getByRole("button", { name: /add alias/i }); + + expect(addButton).toBeDisabled(); + await user.type(screen.getByLabelText("Alias Name"), "fast-model"); + expect(addButton).toBeDisabled(); + + await user.type(screen.getByLabelText("target model"), "gpt-4o"); + expect(addButton).toBeEnabled(); + await user.click(addButton); + expect(onAliasUpdate).toHaveBeenCalledWith({ "fast-model": "gpt-4o" }); + }); + + it("renames an alias through the row editor", async () => { + const { onAliasUpdate, user } = renderInForm(); + await addAlias(user, "fast-model", "gpt-4o"); + + await user.click(screen.getByRole("button", { name: "Edit fast-model" })); + const rowInput = screen.getByLabelText("Edit alias name"); + await user.clear(rowInput); + await user.type(rowInput, "renamed-model"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onAliasUpdate).toHaveBeenLastCalledWith({ "renamed-model": "gpt-4o" }); + }); + + it("drops an alias through the row delete action", async () => { + const { onAliasUpdate, user } = renderInForm(); + await addAlias(user, "fast-model", "gpt-4o"); + + await user.click(screen.getByRole("button", { name: "Delete fast-model" })); + + expect(onAliasUpdate).toHaveBeenLastCalledWith({}); + expect(screen.getByText("No aliases added yet. Add a new alias above.")).toBeInTheDocument(); + }); + + it("does not submit the surrounding form when its own controls are used", async () => { + const { onSubmit, user } = renderInForm(); + + await user.click(screen.getByRole("button", { name: "Save team" })); + expect(onSubmit).toHaveBeenCalledTimes(1); + + await addAlias(user, "fast-model", "gpt-4o"); + const row = screen.getByText("fast-model").closest("tr") as HTMLElement; + await user.click(within(row).getByRole("button", { name: "Edit fast-model" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click(screen.getByRole("button", { name: "Delete fast-model" })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx index 2aecedb8a6e..c6e24d3b470 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx @@ -1,6 +1,8 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useId } from "react"; import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; +import { Button } from "@/components/ui/button"; import { Card, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; import ModelSelector from "./ModelSelector"; import { toast } from "@/lib/toast"; @@ -27,6 +29,7 @@ const ModelAliasManager: React.FC = ({ const [aliases, setAliases] = useState([]); const [newAlias, setNewAlias] = useState({ aliasName: "", targetModel: "" }); const [editingAlias, setEditingAlias] = useState(null); + const aliasNameId = useId(); useEffect(() => { // Convert object to array for display @@ -142,11 +145,14 @@ const ModelAliasManager: React.FC = ({ return (
-

Add New Alias

+

Add New Alias

- - + Alias Name + + @@ -156,11 +162,10 @@ const ModelAliasManager: React.FC = ({ }) } placeholder="e.g., gpt-4o" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
- + = ({ />
- +
-

Manage Existing Aliases

-
+

Manage Existing Aliases

+
@@ -204,8 +205,9 @@ const ModelAliasManager: React.FC = ({ {editingAlias && editingAlias.id === alias.id ? ( <> - setEditingAlias({ @@ -213,7 +215,7 @@ const ModelAliasManager: React.FC = ({ aliasName: e.target.value, }) } - className="w-full px-2 py-1 border border-gray-300 rounded-md text-sm" + className="h-8" /> @@ -232,39 +234,37 @@ const ModelAliasManager: React.FC = ({
- - + +
) : ( <> - {alias.aliasName} - {alias.targetModel} + {alias.aliasName} + {alias.targetModel}
- - + + +
@@ -273,7 +273,7 @@ const ModelAliasManager: React.FC = ({ ))} {aliases.length === 0 && ( - + No aliases added yet. Add a new alias above. @@ -287,12 +287,12 @@ const ModelAliasManager: React.FC = ({ {showExampleConfig && ( Configuration Example -

Here's how your current aliases would look in the config:

-
-
+

Here's how your current aliases would look in the config:

+
+
model_aliases: {Object.keys(aliasObject).length === 0 ? ( - +
  # No aliases configured yet
From 78670789a027ab18de359f714c8b068dfc4b0dd5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:35:35 -0700 Subject: [PATCH 2/3] refactor(ui): replace the agent form's last antd Form.Item with its own field label The agent forms moved to react-hook-form in #37357, which deliberately kept the antd Select for the agent type picker because its dropdownRender footer and two-tier options have no shadcn equivalent. The Form.Item wrapped around it survived as a side effect rather than for that reason: it carries no name and no rules, and the file renders no antd Form at all, so it bound nothing and validated nothing. It was also the only label in the file rendering antd's required asterisk, while AgentFormField renders genuinely required fields without one. Moving it to the file's own Field, FieldLabel and labelWithHint makes it match, and gives the label a control to point at. --- .../agents/_components/add_agent_form.test.tsx | 8 ++++++++ .../agents/_components/add_agent_form.tsx | 14 +++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx index 767e7c2ae5f..3601bc57fed 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx @@ -55,6 +55,14 @@ describe("AddAgentForm logos", () => { expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png")); }); + it("labels the agent type picker so the label points at the control", async () => { + renderForm(); + + await screen.findByAltText("A2A Agent logo"); + + expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox")); + }); + it("renders the option logo when the agent type dropdown is opened", async () => { renderForm(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index da98eb8a352..34f6a879b1e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Form, Modal, Select, Steps, Tag } from "antd"; +import { Modal, Select, Steps, Tag } from "antd"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { toast } from "@/lib/toast"; import { Logo } from "@/components/molecules/logo/Logo"; @@ -679,12 +679,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const renderConfigureStep = () => ( <> - Agent Type
} - required - tooltip="Select the type of agent you want to create" - > + + + {labelWithHint("Agent Type", "Select the type of agent you want to create")} + - +
{agentType === CUSTOM_AGENT_TYPE ? ( From fae700da369dad400359419962354a815b6c8557 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:35:35 -0700 Subject: [PATCH 3/3] refactor(ui): tokenise the access group selector and drop its stale binding note The selector takes value and onChange, which is exactly what lets it sit inside both an antd Form.Item and a react-hook-form FormField, so the doc bullet naming only antd was about to describe half the truth. The props interface already states the contract. That bullet was also the only Form.Item match in the file, and it has twice inflated the migration's canonical tag count, which now needs no subtraction. Its four hardcoded colours move to tokens for the same reason as the rest of the sweep. Its antd Select stays. --- .../components/common_components/AccessGroupSelector.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.tsx b/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.tsx index 4b73fb561d0..fb8629dc8fa 100644 --- a/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.tsx @@ -22,7 +22,6 @@ export interface AccessGroupSelectorProps { * - Displays the **access_group_name** in the dropdown. * - Returns an array of **access_group_id** values. * - Always multi-select since users can assign multiple access groups. - * - Integrates with Ant Design `` out of the box via `value` / `onChange`. */ const AccessGroupSelector: React.FC = ({ value, @@ -42,7 +41,7 @@ const AccessGroupSelector: React.FC = ({ return (
{showLabel && ( -

+

{labelText}

)} @@ -56,7 +55,7 @@ const AccessGroupSelector: React.FC = ({ label: ( {group.access_group_name}{" "} - ({group.access_group_id}) + ({group.access_group_id}) ), value: group.access_group_id, @@ -68,7 +67,7 @@ const AccessGroupSelector: React.FC = ({ return (
{showLabel && ( -

+

{labelText}

)} @@ -83,7 +82,7 @@ const AccessGroupSelector: React.FC = ({ style={{ width: "100%", ...style }} className={`rounded-md ${className ?? ""}`} notFoundContent={ - isError ? Failed to load access groups : "No access groups found" + isError ? Failed to load access groups : "No access groups found" } filterOption={(input, option) => { const searchText = options.find((opt) => opt.value === option?.value)?.searchText ?? "";