From 3465ba4914858ab16f032c8d619ef21cb532bcdd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 03:52:41 -0700 Subject: [PATCH 1/4] refactor(ui): migrate router settings and shared badges off antd and tremor Replaces Ant Design and Tremor in the fallbacks views, the router general settings panel, and the two shared banner and badge components. - Tremor Card, Table and Icon become the ui/card, ui/table and lucide equivalents, reproducing Tremor's icon box so click targets keep their size - antd Alert becomes a composed role="alert" region, since the shadcn CLI's alert pulls in class-variance-authority, which this repo does not have - antd InputNumber becomes a native number input, and Switch onChange becomes onCheckedChange - shadcn TableCell ships whitespace-nowrap where Tremor's did not, so cells holding model names and setting descriptions get whitespace-normal back - adds a DeprecationBanner test covering naming, the link, and dismissal, proven against the antd version first and mutation checked - drops the eslint suppressions these files no longer need --- ui/litellm-dashboard/eslint-suppressions.json | 21 -- .../_components/general_settings.test.tsx | 6 +- .../_components/general_settings.tsx | 246 ++++++++++-------- .../src/components/BetaBadge.tsx | 11 +- .../src/components/DeprecationBanner.test.tsx | 44 ++++ .../src/components/DeprecationBanner.tsx | 61 +++-- .../Fallbacks/EditFallbacks.tsx | 15 +- .../RouterSettings/Fallbacks/Fallbacks.tsx | 115 ++++---- 8 files changed, 306 insertions(+), 213 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..927d0d2b08f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1405,9 +1405,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 2 } @@ -1761,11 +1758,6 @@ "count": 1 } }, - "src/components/BetaBadge.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "no-restricted-imports": { "count": 1 @@ -1789,11 +1781,6 @@ "count": 1 } }, - "src/components/DeprecationBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/ExportSummary.tsx": { "no-restricted-imports": { "count": 1 @@ -1995,11 +1982,6 @@ "count": 1 } }, - "src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": { "local/no-complex-jsx-arrow": { "count": 1 @@ -2017,9 +1999,6 @@ } }, "src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx": { - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 2 } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx index 9ffcbfc9975..0f3ba6c3471 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx @@ -62,6 +62,8 @@ const settingsRow = async (fieldName: string) => { return row as HTMLElement; }; +const numericValueIn = (row: HTMLElement) => Number((within(row).getByRole("spinbutton") as HTMLInputElement).value); + describe("GeneralSettings General tab", () => { beforeEach(() => { vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]); @@ -87,7 +89,7 @@ describe("GeneralSettings General tab", () => { await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); - expect(within(row).getByRole("spinbutton")).toHaveValue("7.50"); + expect(numericValueIn(row)).toBe(7.5); const actionCell = row.querySelectorAll("td")[3]; const resetIcon = actionCell.querySelector("svg"); @@ -95,7 +97,7 @@ describe("GeneralSettings General tab", () => { await user.click(resetIcon as unknown as Element); expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); - expect(within(row).getByRole("spinbutton")).toHaveValue("1.00"); + expect(numericValueIn(row)).toBe(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index ed7b17067d5..2a5b94b2fd7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -1,22 +1,14 @@ import React, { useState, useEffect } from "react"; -import { - Card, - Table, - TableHead, - TableRow, - TableHeaderCell, - TableCell, - TableBody, - Title, - Text, - Button, - Icon, - Switch, -} from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking"; -import { InputNumber, Select as AntdSelect } from "antd"; -import { TrashIcon } from "@heroicons/react/outline"; +import { Trash2 } from "lucide-react"; import { StatusBadge } from "@/components/shared/table_cells"; import RouterSettings from "@/components/router_settings"; @@ -44,16 +36,22 @@ export interface generalSettingsItem { field_default_value?: any; } +const NUMERIC_INPUT_WIDTH = "w-36"; + +const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw)); + const SettingValueEditor: React.FC<{ setting: generalSettingsItem; onChange: (fieldName: string, newValue: any) => void; }> = ({ setting, onChange }) => { if (setting.field_type === "Integer") { return ( - onChange(setting.field_name, newValue)} + className={NUMERIC_INPUT_WIDTH} + value={setting.field_value ?? ""} + onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))} /> ); } @@ -61,42 +59,55 @@ const SettingValueEditor: React.FC<{ return ( onChange(setting.field_name, checked)} + onCheckedChange={(checked) => onChange(setting.field_name, checked)} /> ); } if (setting.field_type === "Float") { return ( - onChange(setting.field_name, newValue)} + className={NUMERIC_INPUT_WIDTH} + value={setting.field_value ?? ""} + onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))} /> ); } if (setting.field_type === "Dollar") { return ( - onChange(setting.field_name, newValue)} - /> + + $ + onChange(setting.field_name, toNumericValue(event.target.value))} + /> + ); } if (setting.field_type === "Select") { return ( - ({ label: option, value: option }))} - onChange={(newValue) => onChange(setting.field_name, newValue ?? "")} - /> + ); } return null; @@ -131,33 +142,43 @@ export const PromptCachingPanel: React.FC<{ return ( - Prompt Caching + + Prompt Caching -
-
- Automatic Anthropic prompt caching -

{enableSetting.field_description}

-
- persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} /> -
- - {ttlSetting && (
-
- Cache lifetime (TTL) -

{ttlSetting.field_description}

+
+

Automatic Anthropic prompt caching

+

{enableSetting.field_description}

- ({ label: option, value: option }))} - onChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")} - /> + persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
- )} + + {ttlSetting && ( +
+
+

Cache lifetime (TTL)

+

{ttlSetting.field_description}

+
+ +
+ )} + ); }; @@ -254,55 +275,60 @@ const GeneralSettings: React.FC = ({ accessToken, user - - - - Setting - Value - Status - Action - - - - {generalSettings - .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) - .map((value, index) => ( - - - {value.field_name} -

- {value.field_description} -

-
- - - - - {value.stored_in_db == true ? ( - - ) : value.stored_in_db == false ? ( - - ) : ( - - )} - - - - handleResetField(value.field_name)}> - Reset - - -
- ))} -
-
+ + + + + Setting + Value + Status + Action + + + + {generalSettings + .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) + .map((value, index) => ( + + +

{value.field_name}

+

+ {value.field_description} +

+
+ + + + + {value.stored_in_db == true ? ( + + ) : value.stored_in_db == false ? ( + + ) : ( + + )} + + + + handleResetField(value.field_name)} + className="inline-flex shrink-0 cursor-pointer items-center justify-center px-1.5 py-1.5 text-red-500" + > + + + +
+ ))} +
+
+
diff --git a/ui/litellm-dashboard/src/components/BetaBadge.tsx b/ui/litellm-dashboard/src/components/BetaBadge.tsx index 7c4ef04417e..4e2195d1c36 100644 --- a/ui/litellm-dashboard/src/components/BetaBadge.tsx +++ b/ui/litellm-dashboard/src/components/BetaBadge.tsx @@ -1,4 +1,4 @@ -import { Badge } from "antd"; +import { Badge } from "@/components/ui/badge"; import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) { @@ -8,11 +8,14 @@ export default function BetaBadge({ children, dot = false }: { children?: React. return children ? <>{children} : null; } + const badge = dot ? : Beta; + return children ? ( - + {children} - + {badge} + ) : ( - + badge ); } diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx new file mode 100644 index 00000000000..596ad2a1626 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { DeprecationBanner } from "./DeprecationBanner"; + +describe("DeprecationBanner", () => { + it("names the deprecated feature in the heading and the body", () => { + render(); + + expect(screen.getByText("Memory is on a draft deprecation list")).toBeInTheDocument(); + expect(screen.getByText(/Memory is one of several experimental features/)).toBeInTheDocument(); + }); + + it("states the target removal date and that the list is not final", () => { + render(); + + expect(screen.getByText(/as early as September 1, 2026/)).toBeInTheDocument(); + expect(screen.getByText(/This list is a draft and is not final/)).toBeInTheDocument(); + }); + + it("links to the deprecation discussion in a new tab without leaking the opener", () => { + render(); + + const link = screen.getByRole("link", { name: "deprecation discussion" }); + expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32090"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("exposes a named close control", () => { + render(); + + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); + }); + + it("hides the banner once the close control is used", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByText("Memory is on a draft deprecation list")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx index 33c75ec22e3..9df34636f82 100644 --- a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx +++ b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx @@ -1,8 +1,8 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; -import { Alert } from "antd"; +import { Info, X } from "lucide-react"; const DEPRECATION_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32090"; const DEPRECATION_TARGET_DATE = "September 1, 2026"; @@ -11,21 +11,42 @@ interface DeprecationBannerProps { featureName: string; } -export const DeprecationBanner: React.FC = ({ featureName }) => ( - - {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `} - - deprecation discussion - - . - - } - type="info" - showIcon - closable - style={{ marginBottom: 16 }} - /> -); +export const DeprecationBanner: React.FC = ({ featureName }) => { + const [isClosed, setIsClosed] = useState(false); + + if (isClosed) { + return null; + } + + return ( +
+ +
+

{`${featureName} is on a draft deprecation list`}

+

+ {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `} + + deprecation discussion + + . +

+
+ +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx index 938e1104301..3efdcfd6b51 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx @@ -4,9 +4,9 @@ * Reuses FallbackGroupConfig with the primary model locked */ -import { Button } from "antd"; +import { Button } from "@/components/ui/button"; import { useQuery } from "@tanstack/react-query"; -import { Pencil } from "lucide-react"; +import { LoaderCircle, Pencil } from "lucide-react"; import React, { useMemo, useState } from "react"; import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import NotificationManager from "../../../molecules/notifications_manager"; @@ -88,16 +88,11 @@ export default function EditFallbacks({ disablePrimaryModel />
- -
diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index 4aa9fb15705..f82780f0c73 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -1,7 +1,7 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; -import { ArrowRightIcon, PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline"; -import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; -import { Tooltip, Typography } from "antd"; +import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import openai from "openai"; import React, { useEffect, useState } from "react"; import DeleteResourceModal from "../../../common_components/DeleteResourceModal"; @@ -18,12 +18,14 @@ type Fallbacks = FallbackEntry[]; const modelCardClass = "inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-gray-200 bg-gray-50 text-sm font-medium text-gray-800 shrink-0"; +const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5"; + function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode { const provider = getProviderFromModel?.(modelName) ?? modelName; return ( - {modelName} + {modelName} ); } @@ -41,19 +43,23 @@ function renderFallbacksChain( return ( - {modelName} + {modelName} ); }; return ( - + {list.map((model, i) => ( - {i > 0 && } + {i > 0 && ( + + + + )} ))} @@ -248,7 +254,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) const canModify = isProxyAdminRole(userRole ?? ""); return ( - <> + {canModify && ( = ({ accessToken, userRole, userID }) )} {!hasFallbacks ? (
- + No fallbacks configured. Add fallbacks to automatically try another model when the primary fails. - +
) : ( - + - Model Name - Fallbacks - Actions + Model Name + Fallbacks + Actions - + {routerSettings["fallbacks"].map((item: FallbackEntry, index: number) => Object.entries(item).map(([key, value]) => ( - {renderModelNameCell(key, getProviderFromModel)} - + + {renderModelNameCell(key, getProviderFromModel)} + + {renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)} {canModify && ( <> - - testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} - className="cursor-pointer hover:text-blue-600" - /> - - - handleEditClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)} - className="cursor-pointer inline-flex" + + testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} + className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`} + /> + } > - - + + + Test fallback - - handleDeleteClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} - className="cursor-pointer inline-flex" + + handleEditClick(item)} + onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)} + className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`} + /> + } > - - + + + Edit fallback + + + handleDeleteClick(item)} + onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} + className={`${iconWrapperClass} cursor-pointer hover:text-red-600`} + /> + } + > + + + Delete fallback )} @@ -350,7 +373,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) onOk={handleDeleteConfirm} confirmLoading={isDeleting} /> - + ); }; From 3a537cce4d9ba30b30e23e3346d4fbfe6dc0b1c2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 04:48:49 -0700 Subject: [PATCH 2/4] refactor(ui): move the model hub and model select onto shadcn primitives Rebuilds public_model_hub, MakeSkillPublicForm, ModelSelect and the guardrail LogViewer on the in-repo shadcn layer, so they inherit the dashboard's design tokens instead of styling themselves through Ant Design and Tremor. Public prop signatures are unchanged, so no caller moves. The two teams e2e steps that reached into antd's Select internals now drive the combobox through its test id, role and data-slot instead. --- tests/e2e/ui/tests/proxy-admin/teams.spec.ts | 16 +- ui/litellm-dashboard/eslint-suppressions.json | 14 - .../GuardrailsMonitor/LogViewer.tsx | 25 +- .../ModelSelect/ModelSelect.test.tsx | 280 +-- .../components/ModelSelect/ModelSelect.tsx | 228 ++- .../MakeSkillPublicForm.tsx | 165 +- .../src/components/public_model_hub.test.tsx | 15 + .../src/components/public_model_hub.tsx | 1741 +++++++++-------- 8 files changed, 1261 insertions(+), 1223 deletions(-) diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 92d22f11f4d..3a63c5be940 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -47,10 +47,10 @@ test.describe("Proxy Admin - Teams", () => { // Fill Team Name — the input has id="team_alias" await dialog.locator("#team_alias").fill(uniqueAlias); - // Select models — the models multi-select is inside the modal - // Click to open dropdown, select "All Proxy Models" - await dialog.locator(".ant-select-selection-overflow").first().click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click(); + // Select models — the models multi-select is inside the modal. Its popup is + // portaled to the body, so scope the option lookup to the page, not the dialog. + await dialog.getByTestId("create-team-models-select").getByRole("combobox").click(); + await page.getByRole("option", { name: "All Proxy Models", exact: true }).click(); await page.keyboard.press("Escape"); // Submit — click the submit button inside the dialog (not the header button) @@ -191,11 +191,11 @@ test.describe("Proxy Admin - Teams", () => { const modelsSelect = page.locator("[data-testid='models-select']"); await expect(modelsSelect).toBeVisible({ timeout: 10_000 }); - const anthropicTag = modelsSelect - .locator(".ant-select-selection-item") + const anthropicChip = modelsSelect + .locator('[data-slot="combobox-chip"]') .filter({ hasText: "fake-anthropic-claude" }); - await expect(anthropicTag).toBeVisible({ timeout: 5_000 }); - await anthropicTag.locator(".ant-select-selection-item-remove").click(); + await expect(anthropicChip).toBeVisible({ timeout: 5_000 }); + await anthropicChip.locator('[data-slot="combobox-chip-remove"]').click(); await page.getByRole("button", { name: "Save Changes" }).click(); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..eb3352a0e30 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1830,9 +1830,6 @@ "src/components/GuardrailsMonitor/LogViewer.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/HelpLink.test.tsx": { @@ -1845,11 +1842,6 @@ "count": 1 } }, - "src/components/ModelSelect/ModelSelect.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": { "max-nested-callbacks": { "count": 12 @@ -2341,9 +2333,6 @@ } }, "src/components/claude_code_plugins/MakeSkillPublicForm.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2982,9 +2971,6 @@ }, "max-lines": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/query_param_input.tsx": { diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx index c3671fc9e2c..d41b29f8218 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx @@ -1,8 +1,9 @@ -import { CheckCircleOutlined, CloseOutlined, DownOutlined, WarningOutlined } from "@ant-design/icons"; +import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import moment from "moment"; -import { Button, Spin } from "antd"; import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { uiSpendLogsCall } from "@/components/networking"; import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer"; import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns"; @@ -13,21 +14,21 @@ const actionConfig: Record< { icon: React.ElementType; color: string; bg: string; border: string; label: string } > = { blocked: { - icon: CloseOutlined, + icon: X, color: "text-red-600", bg: "bg-red-50", border: "border-red-200", label: "Blocked", }, passed: { - icon: CheckCircleOutlined, + icon: CircleCheck, color: "text-green-600", bg: "bg-green-50", border: "border-green-200", label: "Passed", }, flagged: { - icon: WarningOutlined, + icon: TriangleAlert, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200", @@ -125,8 +126,8 @@ export function LogViewer({ {filters.map((f) => ( ); })} diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index e253bc4c0ef..eeaeac541bd 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -1,6 +1,6 @@ import type { ProxyModel } from "@/app/(dashboard)/hooks/models/useModels"; import type { Organization } from "@/components/networking"; -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -22,64 +22,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Select: ({ - value, - onChange, - options, - "data-testid": dataTestId, - allowClear, - maxTagCount, - maxTagPlaceholder, - mode, - ...props - }: any) => { - // Simulate maxTagCount responsive behavior - if value length > 5, call maxTagPlaceholder - const shouldShowPlaceholder = maxTagCount === "responsive" && Array.isArray(value) && value.length > 5; - const visibleValues = shouldShowPlaceholder ? value.slice(0, 5) : value; - const omittedValues = shouldShowPlaceholder ? value.slice(5).map((v: string) => ({ value: v, label: v })) : []; - - return ( -
- - {shouldShowPlaceholder && maxTagPlaceholder && ( -
{maxTagPlaceholder(omittedValues)}
- )} -
- ); - }, - Skeleton: { - Input: ({ active, block }: any) =>
, - }, - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - }; -}); - import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; @@ -108,6 +50,14 @@ const createMockOrganization = (models: string[]): Organization => ({ members: null, }); +const openModelList = async (user: ReturnType) => { + await user.click(screen.getAllByRole("combobox")[0]); + await screen.findByRole("listbox"); +}; + +const expectOffered = (label: string) => expect(screen.queryAllByText(label).length).toBeGreaterThan(0); +const expectNotOffered = (label: string) => expect(screen.queryAllByText(label)).toHaveLength(0); + describe("ModelSelect", () => { const mockProxyModels: ProxyModel[] = [ { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, @@ -138,21 +88,26 @@ describe("ModelSelect", () => { } as any); }); - it("should render with all option groups", async () => { + it("should offer every model and wildcard under its group heading", async () => { + const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - expect(screen.getByText("All Openai models")).toBeInTheDocument(); - expect(screen.getByText("All Anthropic models")).toBeInTheDocument(); - }); + await openModelList(user); + + expectOffered("Wildcard Options"); + expectOffered("gpt-4"); + expectOffered("claude-3"); + expectOffered("All Openai models"); + expectOffered("All Anthropic models"); }); - it("should show skeleton loader when any data is loading", () => { + it("should offer nothing to select while any dependency is loading", () => { + const { unmount: unmountReady } = renderWithProviders(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + unmountReady(); + const loadingScenarios = [ { hook: mockUseAllProxyModels, context: "user" as const }, { hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } }, @@ -168,30 +123,24 @@ describe("ModelSelect", () => { const { unmount } = renderWithProviders(); - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); + expect(screen.queryAllByRole("combobox")).toHaveLength(0); unmount(); }); }); - it("should handle model selection and onChange", async () => { + it("should report the picked model to onChange", async () => { const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); + await openModelList(user); + await user.click(screen.getAllByText("gpt-4")[0]); - const select = screen.getByRole("listbox"); - await user.selectOptions(select, "gpt-4"); expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); - - await user.selectOptions(select, ["gpt-4", "claude-3"]); - expect(mockOnChange).toHaveBeenCalled(); }); - it("should handle special options correctly", async () => { + it("should offer both special options when they are enabled", async () => { const user = userEvent.setup(); mockUseOrganization.mockReturnValue({ data: createMockOrganization(["all-proxy-models"]), @@ -207,33 +156,32 @@ describe("ModelSelect", () => { />, ); - await waitFor(() => { - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - expect(screen.getByText("No Default Models")).toBeInTheDocument(); - }); + await openModelList(user); - const select = screen.getByRole("listbox"); - await user.selectOptions(select, ["all-proxy-models", "no-default-models"]); - expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]); + expectOffered("Special Options"); + expectOffered("All Proxy Models"); + expectOffered("No Default Models"); }); - it("should disable models when special option is selected", async () => { + it("should replace an existing selection when a special option is picked", async () => { + const user = userEvent.setup(); + renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled(); - expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled(); - }); + await openModelList(user); + await user.click(screen.getAllByText("No Default Models")[0]); + + expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]); }); - it("should filter models based on context", async () => { + it("should filter the offered models by context", async () => { const testCases = [ { name: "user context with includeUserModels", @@ -340,6 +288,7 @@ describe("ModelSelect", () => { ]; for (const testCase of testCases) { + const user = userEvent.setup(); testCase.setup(); const { unmount } = renderWithProviders( { />, ); - await waitFor(() => { - testCase.expectedVisible.forEach((model) => { - expect(screen.getByText(model)).toBeInTheDocument(); - }); - testCase.expectedHidden.forEach((model) => { - expect(screen.queryByText(model)).not.toBeInTheDocument(); - }); - }); + await openModelList(user); + testCase.expectedVisible.forEach(expectOffered); + testCase.expectedHidden.forEach(expectNotOffered); unmount(); vi.clearAllMocks(); @@ -368,7 +312,7 @@ describe("ModelSelect", () => { } }); - it("should show All Proxy Models option based on conditions", async () => { + it("should offer All Proxy Models only when the context allows it", async () => { const testCases = [ { name: "when showAllProxyModelsOverride is true", @@ -426,6 +370,7 @@ describe("ModelSelect", () => { ]; for (const testCase of testCases) { + const user = userEvent.setup(); testCase.setup(); const { unmount } = renderWithProviders( { />, ); - await waitFor(() => { - if (testCase.shouldShow) { - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - } else { - expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); - expect(screen.getByText("No Default Models")).toBeInTheDocument(); - } - }); + await openModelList(user); + if (testCase.shouldShow) { + expectOffered("All Proxy Models"); + } else { + expectNotOffered("All Proxy Models"); + expectOffered("No Default Models"); + } unmount(); vi.clearAllMocks(); @@ -454,27 +398,6 @@ describe("ModelSelect", () => { } }); - it("should deduplicate models with same id", async () => { - const duplicateModels: ProxyModel[] = [ - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - ]; - - mockUseAllProxyModels.mockReturnValue({ - data: { data: duplicateModels }, - isLoading: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - const gpt4Options = screen.getAllByText("gpt-4"); - expect(gpt4Options.length).toBeGreaterThan(0); - }); - }); - it("should use custom dataTestId when provided", async () => { renderWithProviders( { />, ); - await waitFor(() => { - expect(screen.getByTestId("custom-test-id")).toBeInTheDocument(); - }); + expect(await screen.findByTestId("custom-test-id")).toBeInTheDocument(); }); it("should return all proxy models for team context when organization has empty models array", async () => { + const user = userEvent.setup(); mockUseTeam.mockReturnValue({ data: { team_id: "team-1", team_alias: "Test Team", models: [] }, isLoading: false, @@ -503,52 +425,66 @@ describe("ModelSelect", () => { renderWithProviders(); - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - }); + await openModelList(user); + + expectOffered("gpt-4"); + expectOffered("claude-3"); }); - it("should disable No Default Models when all-proxy-models is selected", async () => { - mockUseOrganization.mockReturnValue({ - data: createMockOrganization(["all-proxy-models"]), - isLoading: false, - } as any); + it("should not offer a special options group when includeSpecialOptions is omitted", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await openModelList(user); + + expectNotOffered("Special Options"); + expectNotOffered("All Proxy Models"); + expectNotOffered("No Default Models"); + expectOffered("Models"); + }); + + it("should mark models and wildcards unselectable while a special option is selected", async () => { + const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - const noDefaultOption = screen.getByRole("option", { name: "No Default Models" }); - expect(noDefaultOption).toBeDisabled(); - }); + await openModelList(user); + + expect(screen.getByRole("option", { name: "gpt-4" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "All Openai models" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "No Default Models" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "All Proxy Models" })).not.toHaveAttribute("aria-disabled", "true"); }); - it("should not render an empty optgroup when includeSpecialOptions is omitted", async () => { - renderWithProviders(); + it("should list a duplicated proxy model only once", async () => { + const user = userEvent.setup(); + mockUseAllProxyModels.mockReturnValue({ + data: { + data: [ + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + ], + }, + isLoading: false, + } as any); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); + renderWithProviders( + , + ); - const optgroups = document.querySelectorAll("optgroup"); - // Wildcard Options + Models — no blank leading group - expect(optgroups.length).toBe(2); - optgroups.forEach((g) => { - expect(g.getAttribute("label")).toBeTruthy(); - }); + await openModelList(user); + + expect(screen.getAllByRole("option", { name: "gpt-4" })).toHaveLength(1); }); - it("should render maxTagPlaceholder when many items are selected", async () => { - // Create many models to trigger maxTagCount responsive behavior - const manyModels: ProxyModel[] = Array.from({ length: 20 }, (_, i) => ({ + it("should collapse selections past the chip limit into a labelled overflow count", async () => { + const manyModels: ProxyModel[] = Array.from({ length: 8 }, (_, i) => ({ id: `model-${i}`, object: "model", created: 1234567890, @@ -560,22 +496,18 @@ describe("ModelSelect", () => { isLoading: false, } as any); - const selectedValues = manyModels.slice(0, 10).map((m) => m.id); - renderWithProviders( m.id)} context="user" options={{ showAllProxyModelsOverride: true }} />, ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - // Verify maxTagPlaceholder is rendered with omitted values - expect(screen.getByTestId("max-tag-placeholder")).toBeInTheDocument(); - expect(screen.getByText(/\+5 more/)).toBeInTheDocument(); - }); + expect(await screen.findByText("+3 more")).toBeInTheDocument(); + expect(screen.getByLabelText("model-0")).toBeInTheDocument(); + expect(screen.getByLabelText("model-4")).toBeInTheDocument(); + expect(screen.queryByLabelText("model-5")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index e993fed2408..55aa1f1ec5f 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -2,7 +2,22 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import { Select, Skeleton, Tooltip } from "antd"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxGroup, + ComboboxItem, + ComboboxLabel, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Organization, Team } from "../networking"; import { splitWildcardModels } from "./modelUtils"; @@ -21,6 +36,8 @@ export const MODEL_SENTINEL_OPTIONS = [ MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE, ] as const; +const MAX_VISIBLE_MODEL_CHIPS = 5; + export interface ModelSelectProps { teamID?: string; organizationID?: string; @@ -37,6 +54,17 @@ export interface ModelSelectProps { style?: React.CSSProperties; } +type ModelOption = { + label: string; + value: string; + disabled?: boolean; +}; + +type ModelOptionGroup = { + label: string; + items: ModelOption[]; +}; + type FilterContextArgs = { allProxyModels: string[]; selectedTeam?: Team; @@ -109,10 +137,11 @@ export const ModelSelect = (props: ModelSelectProps) => { showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global"; if (isLoading) { - return ; + return ; } - const handleChange = (values: string[]) => { + const handleChange = (selected: ModelOption[]) => { + const values = selected.map((option) => option.value); const specialValues = values.filter(isSpecialOption); let finalValues: string[]; @@ -133,85 +162,122 @@ export const ModelSelect = (props: ModelSelectProps) => { }); const { wildcard, regular } = splitWildcardModels(filteredModels); - return ( - setSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
- -
- Provider: - -
-
- Mode: - -
-
- Features: - -
- - - model.model_group || String(index)} - sortingMode="client" - sorting={modelSorting} - onSortingChange={setModelSorting} - isLoading={loading} - loadingMessage="Loading models…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredData.length} of {modelHubData?.length || 0} models - -
- - - {/* Agents Tab */} - {agentHubData && Array.isArray(agentHubData) && agentHubData.length > 0 && ( - -
- Available Agents -
- - {/* Filters */} -
-
-
- Search Agents: - - - -
-
- - setAgentSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
-
-
- Skills: - -
-
- - agent.name || String(index)} - sortingMode="client" - sorting={agentSorting} - onSortingChange={setAgentSorting} - isLoading={agentLoading} - loadingMessage="Loading agents…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents - -
-
- )} - - {/* MCP Servers Tab */} - {mcpHubData && Array.isArray(mcpHubData) && mcpHubData.length > 0 && ( - -
- Available MCP Servers -
- - {/* Filters */} -
-
-
- Search MCP Servers: - - - -
-
- - setMcpSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
-
-
- Transport: - -
-
- - server.server_id || String(index)} - sortingMode="client" - sorting={mcpSorting} - onSortingChange={setMcpSorting} - isLoading={mcpLoading} - loadingMessage="Loading MCP servers…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers - -
-
- )} - - {/* Skill Hub Tab */} - - - - - - - - {/* Model Details Modal */} - - {selectedModel?.model_group || "Model Details"} - {selectedModel && ( - - copyToClipboard(selectedModel.model_group)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - - )} - - } - width={1000} - open={isModalVisible} - footer={null} - onOk={handleModalOk} - onCancel={handleModalCancel} - > - {selectedModel && ( -
- {/* Model Overview */} -
- Model Overview -
-
- Model Name: - {selectedModel.model_group} -
-
- Mode: - {selectedModel.mode || "Not specified"} -
-
- Providers: -
- {(selectedModel.providers ?? []).map((provider) => { - const { logo } = getProviderLogoAndName(provider); - return ( - -
- {logo && ( - {provider} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {provider} -
-
- ); - })} -
-
-
- - {/* Wildcard Routing Note */} - {selectedModel.model_group.includes("*") && ( -
-
- -
- Wildcard Routing - - This model uses wildcard routing. You can pass any value where you see the{" "} - * symbol. - - - For example, with{" "} - - {selectedModel.model_group} - - , you can use any string ( - - {selectedModel.model_group.replaceAll("*", "my-custom-value")} - - ) that matches this pattern. - -
-
-
- )} -
- - {/* Token and Cost Information */} -
- Token & Cost Information -
-
- Max Input Tokens: - {selectedModel.max_input_tokens?.toLocaleString() || "Not specified"} -
-
- Max Output Tokens: - {selectedModel.max_output_tokens?.toLocaleString() || "Not specified"} -
-
- Input Cost per 1M Tokens: - - {selectedModel.input_cost_per_token - ? formatCost(selectedModel.input_cost_per_token) - : "Not specified"} - -
-
- Output Cost per 1M Tokens: - - {selectedModel.output_cost_per_token - ? formatCost(selectedModel.output_cost_per_token) - : "Not specified"} - -
-
-
- - {/* Capabilities */} -
- Capabilities -
- {(() => { - const capabilities = getModelCapabilities(selectedModel); - const colors = ["green", "blue", "purple", "orange", "red", "yellow"]; - - if (capabilities.length === 0) { - return No special capabilities listed; - } - - return capabilities.map((capability, index) => ( - - {formatCapabilityName(capability)} - - )); - })()} -
-
- - {/* Rate Limits */} - {(selectedModel.tpm || selectedModel.rpm) && ( -
- Rate Limits -
- {selectedModel.tpm && ( -
- Tokens per Minute: - {selectedModel.tpm.toLocaleString()} -
- )} - {selectedModel.rpm && ( -
- Requests per Minute: - {selectedModel.rpm.toLocaleString()} -
- )} -
-
- )} - - {/* Supported OpenAI Parameters */} - {selectedModel.supported_openai_params && selectedModel.supported_openai_params.length > 0 && ( -
- Supported OpenAI Parameters -
- {selectedModel.supported_openai_params.map((param) => ( - - {param} - + +

{title}

+ ))} -
- )} + + )} - {/* Usage Example */} -
- Usage Example -
-
-                    {(() => {
-                      const codeSnippet = generateCodeSnippet({
-                        apiKeySource: "custom",
-                        accessToken: null,
-                        apiKey: "your_api_key",
-                        inputMessage: "Hello, how are you?",
-                        chatHistory: [{ role: "user", content: "Hello, how are you?", isImage: false } as MessageType],
-                        selectedTags: [],
-                        selectedVectorStores: [],
-                        selectedGuardrails: [],
-                        selectedPolicies: [],
-                        selectedMCPServers: [],
-                        endpointType: getEndpointType(selectedModel.mode || "chat"),
-                        selectedModel: selectedModel.model_group,
-                        selectedSdk: "openai",
-                      });
-                      return codeSnippet;
-                    })()}
-                  
+ {/* Health and Endpoint Status - only shown when not embedded */} + {!isEmbedded && ( + +

Health and Endpoint Status

+
+

Service status: {serviceStatus}

-
- -
-
-
- )} - + + )} - {/* Agent Details Modal */} - - {selectedAgent?.name || "Agent Details"} - {selectedAgent && ( - - copyToClipboard(selectedAgent.name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - - )} -
- } - width={1000} - open={isAgentModalVisible} - footer={null} - onOk={handleAgentModalOk} - onCancel={handleAgentModalCancel} - > - {selectedAgent && ( -
- {/* Agent Overview */} -
- Agent Overview -
-
- Name: - {selectedAgent.name} + {/* Tabs for Models and Agents */} + + + + Model Hub + {hasAgents && Agent Hub} + {hasMcpServers && MCP Hub} + Skill Hub + + + {/* Models Tab */} + +
+

Available Models

-
- Version: - {selectedAgent.version} -
-
- Description: - {selectedAgent.description} -
- {selectedAgent.url && ( + + {/* Filters */} +
- URL: - - {selectedAgent.url} - +
+

Search Models:

+ + } /> + + Smart search with relevance ranking - finds models containing your search terms, ranked by + relevance. Try searching 'xai grok-4', 'claude-4', 'gpt-4', or + 'sonnet' + + +
+
+ + setSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Provider:

+ setSelectedProviders(values)} + > + + + {(values: string[]) => + values.map((provider) => ( + + {provider} + + )) + } + + + + + No providers found + + {(provider: string) => { + const { logo } = getProviderLogoAndName(provider); + return ( + + + {logo && ( + {provider} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {provider} + + + ); + }} + + + +
+
+

Mode:

+ +
+
+

Features:

+
- )} -
-
- - {/* Capabilities */} - {selectedAgent.capabilities && ( -
- Capabilities -
- {Object.entries(selectedAgent.capabilities) - .filter(([_, value]) => value === true) - .map(([key]) => ( - - {key} - - ))}
-
- )} - {/* Skills */} - {selectedAgent.skills && selectedAgent.skills.length > 0 && ( -
- Skills -
- {selectedAgent.skills.map((skill, index) => ( -
-
+ model.model_group || String(index)} + sortingMode="client" + sorting={modelSorting} + onSortingChange={setModelSorting} + isLoading={loading} + loadingMessage="Loading models…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredData.length} of {modelHubData?.length || 0} models +

+
+ + + {/* Agents Tab */} + {hasAgents && ( + +
+

Available Agents

+
+ + {/* Filters */} +
+
+
+

Search Agents:

+ + } /> + Search agents by name or description + +
+
+ + setAgentSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Skills:

+ +
+
+ + agent.name || String(index)} + sortingMode="client" + sorting={agentSorting} + onSortingChange={setAgentSorting} + isLoading={agentLoading} + loadingMessage="Loading agents…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents +

+
+
+ )} + + {/* MCP Servers Tab */} + {hasMcpServers && ( + +
+

Available MCP Servers

+
+ + {/* Filters */} +
+
+
+

Search MCP Servers:

+ + } /> + Search MCP servers by name or description + +
+
+ + setMcpSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Transport:

+ +
+
+ + server.server_id || String(index)} + sortingMode="client" + sorting={mcpSorting} + onSortingChange={setMcpSorting} + isLoading={mcpLoading} + loadingMessage="Loading MCP servers…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers +

+
+
+ )} + + {/* Skill Hub Tab */} + + + + + +
+ + {/* Model Details Modal */} + !open && handleModalCancel()}> + + + + {selectedModel?.model_group || "Model Details"} + {selectedModel && ( + + copyToClipboard(selectedModel.model_group)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy model name + + )} + + + {selectedModel && ( +
+ {/* Model Overview */} +
+

Model Overview

+
+
+

Model Name:

+

{selectedModel.model_group}

+
+
+

Mode:

+

{selectedModel.mode || "Not specified"}

+
+
+

Providers:

+
+ {(selectedModel.providers ?? []).map((provider) => { + const { logo } = getProviderLogoAndName(provider); + return ( + +
+ {logo && ( + {provider} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {provider} +
+
+ ); + })} +
+
+
+ + {/* Wildcard Routing Note */} + {selectedModel.model_group.includes("*") && ( +
+
+
- {skill.name} - {skill.description} +

Wildcard Routing

+

+ This model uses wildcard routing. You can pass any value where you see the{" "} + * symbol. +

+

+ For example, with{" "} + + {selectedModel.model_group} + + , you can use any string ( + + {selectedModel.model_group.replaceAll("*", "my-custom-value")} + + ) that matches this pattern. +

- {skill.tags && skill.tags.length > 0 && ( -
- {skill.tags.map((tag) => ( - - {tag} - - ))} +
+ )} +
+ + {/* Token and Cost Information */} +
+

Token & Cost Information

+
+
+

Max Input Tokens:

+

{selectedModel.max_input_tokens?.toLocaleString() || "Not specified"}

+
+
+

Max Output Tokens:

+

{selectedModel.max_output_tokens?.toLocaleString() || "Not specified"}

+
+
+

Input Cost per 1M Tokens:

+

+ {selectedModel.input_cost_per_token + ? formatCost(selectedModel.input_cost_per_token) + : "Not specified"} +

+
+
+

Output Cost per 1M Tokens:

+

+ {selectedModel.output_cost_per_token + ? formatCost(selectedModel.output_cost_per_token) + : "Not specified"} +

+
+
+
+ + {/* Capabilities */} +
+

Capabilities

+
+ {(() => { + const capabilities = getModelCapabilities(selectedModel); + + if (capabilities.length === 0) { + return

No special capabilities listed

; + } + + return capabilities.map((capability) => ( + + {formatCapabilityName(capability)} + + )); + })()} +
+
+ + {/* Rate Limits */} + {(selectedModel.tpm || selectedModel.rpm) && ( +
+

Rate Limits

+
+ {selectedModel.tpm && ( +
+

Tokens per Minute:

+

{selectedModel.tpm.toLocaleString()}

+
+ )} + {selectedModel.rpm && ( +
+

Requests per Minute:

+

{selectedModel.rpm.toLocaleString()}

)}
- ))} -
-
- )} - - {/* Input/Output Modes */} -
- Input/Output Modes -
-
- Input Modes: -
- {(selectedAgent.defaultInputModes ?? []).map((mode) => ( - - {mode} - - ))}
-
+ )} + + {/* Supported OpenAI Parameters */} + {selectedModel.supported_openai_params && selectedModel.supported_openai_params.length > 0 && ( +
+

Supported OpenAI Parameters

+
+ {selectedModel.supported_openai_params.map((param) => ( + + {param} + + ))} +
+
+ )} + + {/* Usage Example */}
- Output Modes: -
- {(selectedAgent.defaultOutputModes ?? []).map((mode) => ( - - {mode} - - ))} +

Usage Example

+
+
+                        {(() => {
+                          const codeSnippet = generateCodeSnippet({
+                            apiKeySource: "custom",
+                            accessToken: null,
+                            apiKey: "your_api_key",
+                            inputMessage: "Hello, how are you?",
+                            chatHistory: [
+                              { role: "user", content: "Hello, how are you?", isImage: false } as MessageType,
+                            ],
+                            selectedTags: [],
+                            selectedVectorStores: [],
+                            selectedGuardrails: [],
+                            selectedPolicies: [],
+                            selectedMCPServers: [],
+                            endpointType: getEndpointType(selectedModel.mode || "chat"),
+                            selectedModel: selectedModel.model_group,
+                            selectedSdk: "openai",
+                          });
+                          return codeSnippet;
+                        })()}
+                      
+
+
+
-
- - {/* Documentation */} - {selectedAgent.documentationUrl && ( -
- Documentation - - - View Documentation - -
)} + +
- {/* A2A Usage Example */} -
- Usage Example (A2A Protocol) + {/* Agent Details Modal */} + !open && handleAgentModalCancel()}> + + + + {selectedAgent?.name || "Agent Details"} + {selectedAgent && ( + + copyToClipboard(selectedAgent.name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy agent name + + )} + + + {selectedAgent && ( +
+ {/* Agent Overview */} +
+

Agent Overview

+
+
+

Name:

+

{selectedAgent.name}

+
+
+

Version:

+

{selectedAgent.version}

+
+
+

Description:

+

{selectedAgent.description}

+
+ {selectedAgent.url && ( + + )} +
+
- {/* Step 1: Retrieve Agent Card */} -
- Step 1: Retrieve Agent Card -
-
-                      {`base_url = '${selectedAgent.url}'
+                  {/* Capabilities */}
+                  {selectedAgent.capabilities && (
+                    
+

Capabilities

+
+ {Object.entries(selectedAgent.capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => ( + + {key} + + ))} +
+
+ )} + + {/* Skills */} + {selectedAgent.skills && selectedAgent.skills.length > 0 && ( +
+

Skills

+
+ {selectedAgent.skills.map((skill, index) => ( +
+
+
+

{skill.name}

+

{skill.description}

+
+
+ {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Input/Output Modes */} +
+

Input/Output Modes

+
+
+

Input Modes:

+
+ {(selectedAgent.defaultInputModes ?? []).map((mode) => ( + + {mode} + + ))} +
+
+
+

Output Modes:

+
+ {(selectedAgent.defaultOutputModes ?? []).map((mode) => ( + + {mode} + + ))} +
+
+
+
+ + {/* Documentation */} + {selectedAgent.documentationUrl && ( +
+

Documentation

+ + + View Documentation + +
+ )} + + {/* A2A Usage Example */} +
+

Usage Example (A2A Protocol)

+ + {/* Step 1: Retrieve Agent Card */} +
+

Step 1: Retrieve Agent Card

+
+
+                          {`base_url = '${selectedAgent.url}'
 
 resolver = A2ACardResolver(
     httpx_client=httpx_client,
@@ -1251,12 +1275,12 @@ if _public_card.supports_authenticated_extended_card:
             f'Failed to fetch extended agent card: {e_extended}. Will proceed with public card.',
             exc_info=True,
         )`}
-                    
-
-
-
+
+
+ -
-
+ copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
- {/* Step 2: Call the Agent */} -
- Step 2: Call the Agent -
-
-                      {`client = A2AClient(
+                    {/* Step 2: Call the Agent */}
+                    
+

Step 2: Call the Agent

+
+
+                          {`client = A2AClient(
     httpx_client=httpx_client, agent_card=final_agent_card_to_use
 )
 
@@ -1333,12 +1357,12 @@ request = SendMessageRequest(
 
 response = await client.send_message(request)
 print(response.model_dump(mode='json', exclude_none=True))`}
-                    
-
-
-
+
+
+ + copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
-
-
- )} - - - {/* MCP Server Details Modal */} - - {selectedMcpServer?.server_name || "MCP Server Details"} - {selectedMcpServer && ( - - copyToClipboard(selectedMcpServer.server_name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - )} -
- } - width={1000} - open={isMcpModalVisible} - footer={null} - onOk={handleMcpModalOk} - onCancel={handleMcpModalCancel} - > - {selectedMcpServer && ( -
- {/* Server Overview */} -
- Server Overview -
+ + + + {/* MCP Server Details Modal */} + !open && handleMcpModalCancel()}> + + + + {selectedMcpServer?.server_name || "MCP Server Details"} + {selectedMcpServer && ( + + copyToClipboard(selectedMcpServer.server_name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy server name + + )} + + + {selectedMcpServer && ( +
+ {/* Server Overview */}
- Server Name: - {selectedMcpServer.server_name} +

Server Overview

+
+
+

Server Name:

+

{selectedMcpServer.server_name}

+
+
+

Transport:

+ {selectedMcpServer.transport} +
+ {selectedMcpServer.alias && ( +
+

Alias:

+

{selectedMcpServer.alias}

+
+ )} +
+

Auth Type:

+ + {selectedMcpServer.auth_type} + +
+
+

Description:

+

{selectedMcpServer.mcp_info?.description || "-"}

+
+
-
- Transport: - {selectedMcpServer.transport} -
- {selectedMcpServer.alias && ( + + {/* Additional Info */} + {selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && (
- Alias: - {selectedMcpServer.alias} +

Additional Information

+
+
+                          {JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
+                        
+
)} + + {/* Usage Example */}
- Auth Type: - - {selectedMcpServer.auth_type} - -
-
- Description: - {selectedMcpServer.mcp_info?.description || "-"} -
-
-
- - {/* Additional Info */} - {selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && ( -
- Additional Information -
-
{JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
-
-
- )} - - {/* Usage Example */} -
- Usage Example -
-
-                    {`# Using MCP Server with Python FastMCP
+                    

Usage Example

+
+
+                        {`# Using MCP Server with Python FastMCP
 
 from fastmcp import Client
 import asyncio
@@ -1474,12 +1501,12 @@ async def main():
 
 if __name__ == "__main__":
     asyncio.run(main())`}
-                  
-
-
-
+
+
+ + copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
-
-
- )} -
- + )} + + + + ); }; From 0ee47a00283926bf0ac7a89b801b5513dee9084f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:12:28 -0700 Subject: [PATCH 3/4] test(ui): cover appending a second model in ModelSelect The rewritten suite only ever picked one ordinary model, so a regression that replaced the selection instead of appending to it would have gone unnoticed. The case passes against the antd version too, so it pins behavior the migration preserves rather than adds. --- .../components/ModelSelect/ModelSelect.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index eeaeac541bd..34a21122027 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -140,6 +140,23 @@ describe("ModelSelect", () => { expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); }); + it("should append a second model to the existing selection", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await openModelList(user); + await user.click(screen.getAllByText("claude-3")[0]); + + expect(mockOnChange).toHaveBeenCalledWith(["gpt-4", "claude-3"]); + }); + it("should offer both special options when they are enabled", async () => { const user = userEvent.setup(); mockUseOrganization.mockReturnValue({ From 7e375ed6e8ca6371a02b7bb2a22c001a8d0c6435 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 09:48:49 -0700 Subject: [PATCH 4/4] chore: retrigger e2e gate