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 ? ( 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 ?? ""; 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