From 18bc2211ab9df74e8c6261b04a38c48278968318 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 11:30:58 +0000 Subject: [PATCH] feat(ui): migrate model_info_view to shadcn + RHF - Replace antd Form.useForm/Form.Item with react-hook-form + Controller - Replace Tremor Card/Grid/TabGroup/TabList/TabPanels with shadcn Card + Tabs - Replace Tremor Title/Text/TextInput/Button with native h/p + shadcn Input/Button + lucide icons - Replace antd Button/Input/Select/Tooltip/Modal with shadcn equivalents - Add TagsLikeMultiSelect (mirrors antd Select mode="tags") and SearchableSingleSelect (mirrors antd Select showSearch allowClear) - VectorStoreSelector now receives value via Controller (field.value) - credentials modals: switch UploadProps import from antd/es/upload to the local add_model_upload_types shim so eslint no-banned-ui-imports stays green - model_info_view.test: mock vectorStoreListCall (now called in edit mode via VectorStoreSelector) Co-authored-by: yuneng-jiang --- .../model_add/AddCredentialModal.tsx | 2 +- .../model_add/EditCredentialModal.tsx | 2 +- .../src/components/model_add/credentials.tsx | 2 +- .../src/components/model_info_view.test.tsx | 1 + .../src/components/model_info_view.tsx | 2209 +++++++++++------ 5 files changed, 1406 insertions(+), 810 deletions(-) diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx index 5b2a9c0d670..833b6405564 100644 --- a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx @@ -20,7 +20,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { UploadProps } from "antd/es/upload"; +import type { UploadProps } from "../add_model/add_model_upload_types"; import React, { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import ProviderSpecificFields from "../add_model/provider_specific_fields"; diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx index 6791fc8c9fd..e8b13011742 100644 --- a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx @@ -20,7 +20,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { UploadProps } from "antd/es/upload"; +import type { UploadProps } from "../add_model/add_model_upload_types"; import { useEffect, useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import ProviderSpecificFields from "../add_model/provider_specific_fields"; diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index 80ab593aed8..75ab163ce0a 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -17,7 +17,7 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -import { UploadProps } from "antd/es/upload"; +import { UploadProps } from "../add_model/add_model_upload_types"; import { useState } from "react"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import NotificationsManager from "../molecules/notifications_manager"; diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 79e41508bb0..8990eb7c69c 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -30,6 +30,7 @@ vi.mock("./networking", () => ({ modelPatchUpdateCall: vi.fn(), modelDeleteCall: vi.fn(), credentialCreateCall: vi.fn(), + vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }), })); const mockUseModelsInfo = vi.fn(); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index c01562ffc35..d829e37c047 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -1,28 +1,42 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer"; -import { Info as InfoCircleOutlined } from "lucide-react"; -import { ArrowLeft as ArrowLeftIcon, Key as KeyIcon, RefreshCcw as RefreshIcon, Trash2 as TrashIcon } from "lucide-react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; import { - Card, - Grid, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, - Title, - Button as TremorButton, -} from "@tremor/react"; -import { Button, Form, Input, Modal, Select, Tooltip } from "antd"; -import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; -import { CheckIcon, CopyIcon } from "lucide-react"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + ArrowLeft as ArrowLeftIcon, + Check as CheckIcon, + Copy as CopyIcon, + Info as InfoCircleOutlined, + Key as KeyIcon, + RefreshCcw as RefreshIcon, + Trash2 as TrashIcon, +} from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { + Controller, + FormProvider, + useForm, + useFormContext, +} from "react-hook-form"; import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils"; -import { formItemValidateJSON, truncateString } from "../utils/textUtils"; +import { truncateString } from "../utils/textUtils"; import CacheControlSettings from "./add_model/cache_control_settings"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; @@ -43,6 +57,7 @@ import { import { getProviderLogoAndName } from "./provider_info_helpers"; import NumericalInput from "./shared/numerical_input"; import { Tag } from "./tag_management/types"; +import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import { getDisplayModelName } from "./view_model/model_name_display"; interface ModelInfoViewProps { @@ -51,10 +66,973 @@ interface ModelInfoViewProps { accessToken: string | null; userID: string | null; userRole: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any onModelUpdate?: (updatedModel: any) => void; modelAccessGroups: string[] | null; } +type ModelSettingsFormValues = { + model_name: string; + litellm_model_name: string; + api_base: string; + custom_llm_provider: string; + organization: string; + tpm: number | null; + rpm: number | null; + max_retries: number | null; + timeout: number | null; + stream_timeout: number | null; + input_cost: number | null; + output_cost: number | null; + cache_control: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cache_control_injection_points: any[]; + model_access_group: string[]; + guardrails: string[]; + vector_store_ids: string[] | undefined; + tags: string[]; + health_check_model: string | null; + litellm_credential_name: string; + litellm_extra_params: string; + model_info: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + litellm_params?: Record; +}; + +/** + * Shadcn-style multi-select chip input that also supports free-text + * creation of new entries (mirrors antd's `Select mode="tags"` behavior). + */ +function TagsLikeMultiSelect({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { value: string; label: string; title?: string }[]; + placeholder?: string; +}) { + const [inputValue, setInputValue] = useState(""); + + const addValue = (v: string) => { + const trimmed = v.trim(); + if (!trimmed) return; + if (!value.includes(trimmed)) { + onChange([...value, trimmed]); + } + }; + + const remove = (v: string) => { + onChange(value.filter((x) => x !== v)); + }; + + return ( +
+
+ {value.map((v) => ( + + {options.find((o) => o.value === v)?.label ?? v} + + + ))} + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addValue(inputValue); + setInputValue(""); + } else if ( + e.key === "Backspace" && + inputValue === "" && + value.length > 0 + ) { + remove(value[value.length - 1]); + } + }} + onBlur={() => { + if (inputValue) { + addValue(inputValue); + setInputValue(""); + } + }} + className="flex-1 min-w-[100px] outline-none bg-transparent text-sm" + /> +
+ {options.length > 0 && ( +
+ {options + .filter((o) => !value.includes(o.value)) + .slice(0, 10) + .map((o) => ( + + ))} +
+ )} +
+ ); +} + +/** + * Shadcn-style single-select with search + clear (mirrors antd + * `Select showSearch allowClear`). + */ +function SearchableSingleSelect({ + value, + onChange, + options, + placeholder, +}: { + value: string; + onChange: (next: string) => void; + options: { value: string; label: string }[]; + placeholder?: string; +}) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const filtered = options.filter((o) => + o.label.toLowerCase().includes(query.toLowerCase()), + ); + const selected = options.find((o) => o.value === value); + return ( +
+
+ { + setOpen(true); + setQuery(""); + }} + onBlur={() => setTimeout(() => setOpen(false), 150)} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + /> + {value && ( + + )} +
+ {open && ( +
+ {filtered.length === 0 ? ( +
+ No results +
+ ) : ( + filtered.map((o) => ( + + )) + )} +
+ )} +
+ ); +} + +/** + * Internal settings form — rendered inside the FormProvider so child + * components (`CacheControlSettings`, etc.) can read/write via + * `useFormContext`. + */ +function SettingsForm({ + isEditing, + localModelData, + guardrailsList, + tagsList, + credentialsList, + modelAccessGroups, + modelHubData, + accessToken, + isWildcardModel, + isAutoRouter, + canEditModel, + onEdit, + onCancel, + isSaving, + showCacheControl, + setShowCacheControl, + onEditAutoRouter, + modelData, +}: { + isEditing: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + localModelData: any; + guardrailsList: string[]; + tagsList: Record; + credentialsList: CredentialItem[]; + modelAccessGroups: string[] | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelHubData: any; + accessToken: string | null; + isWildcardModel: boolean; + isAutoRouter: boolean; + canEditModel: boolean; + onEdit: () => void; + onCancel: () => void; + isSaving: boolean; + showCacheControl: boolean; + setShowCacheControl: (checked: boolean) => void; + onEditAutoRouter: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelData: any; +}) { + const form = useFormContext(); + const { register, control } = form; + + const tagOptions = Object.values(tagsList).map((tag: Tag) => ({ + value: tag.name, + label: tag.name, + title: tag.description || tag.name, + })); + const guardrailOptions = guardrailsList.map((name) => ({ + value: name, + label: name, + })); + const accessGroupOptions = + modelAccessGroups?.map((g) => ({ value: g, label: g })) ?? []; + + return ( +
+
+
+

Model Name

+ {isEditing ? ( + + ) : ( +
+ {localModelData.model_name} +
+ )} +
+ +
+

LiteLLM Model Name

+ {isEditing ? ( + + ) : ( +
+ {localModelData.litellm_model_name} +
+ )} +
+ +
+

Input Cost (per 1M tokens)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData?.litellm_params?.input_cost_per_token + ? ( + localModelData.litellm_params.input_cost_per_token * 1_000_000 + ).toFixed(4) + : localModelData?.model_info?.input_cost_per_token + ? ( + localModelData.model_info.input_cost_per_token * 1_000_000 + ).toFixed(4) + : "Not Set"} +
+ )} +
+ +
+

Output Cost (per 1M tokens)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData?.litellm_params?.output_cost_per_token + ? ( + localModelData.litellm_params.output_cost_per_token * + 1_000_000 + ).toFixed(4) + : localModelData?.model_info?.output_cost_per_token + ? ( + localModelData.model_info.output_cost_per_token * + 1_000_000 + ).toFixed(4) + : "Not Set"} +
+ )} +
+ +
+

API Base

+ {isEditing ? ( + + ) : ( +
+ {localModelData.litellm_params?.api_base || "Not Set"} +
+ )} +
+ +
+

Custom LLM Provider

+ {isEditing ? ( + + ) : ( +
+ {localModelData.litellm_params?.custom_llm_provider || "Not Set"} +
+ )} +
+ +
+

Organization

+ {isEditing ? ( + + ) : ( +
+ {localModelData.litellm_params?.organization || "Not Set"} +
+ )} +
+ +
+

TPM (Tokens per Minute)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.tpm || "Not Set"} +
+ )} +
+ +
+

RPM (Requests per Minute)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.rpm || "Not Set"} +
+ )} +
+ +
+

Max Retries

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.max_retries || "Not Set"} +
+ )} +
+ +
+

Timeout (seconds)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.timeout || "Not Set"} +
+ )} +
+ +
+

Stream Timeout (seconds)

+ {isEditing ? ( + ( + + field.onChange( + typeof v === "number" ? v : v === "" || v == null ? null : Number(v), + ) + } + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.stream_timeout || "Not Set"} +
+ )} +
+ +
+

Model Access Groups

+ {isEditing ? ( + ( + + )} + /> + ) : ( +
+ {localModelData.model_info?.access_groups ? ( + Array.isArray(localModelData.model_info.access_groups) ? ( + localModelData.model_info.access_groups.length > 0 ? ( +
+ {localModelData.model_info.access_groups.map( + (group: string, index: number) => ( + + {group} + + ), + )} +
+ ) : ( + "No groups assigned" + ) + ) : ( + localModelData.model_info.access_groups + ) + ) : ( + "Not Set" + )} +
+ )} +
+ +
+

+ Guardrails + + + + e.stopPropagation()} + > + + + + + Apply safety guardrails to this model to filter content or enforce policies + + + +

+ {isEditing ? ( + ( + + )} + /> + ) : ( +
+ {localModelData.litellm_params?.guardrails ? ( + Array.isArray(localModelData.litellm_params.guardrails) ? ( + localModelData.litellm_params.guardrails.length > 0 ? ( +
+ {localModelData.litellm_params.guardrails.map( + (guardrail: string, index: number) => ( + + {guardrail} + + ), + )} +
+ ) : ( + "No guardrails assigned" + ) + ) : ( + localModelData.litellm_params.guardrails + ) + ) : ( + "Not Set" + )} +
+ )} +
+ +
+

+ Attached Knowledge Bases (RAG) + + + + e.stopPropagation()} + > + + + + + Vector stores used for RAG. Every request to this model will automatically retrieve context from these knowledge bases. + + + +

+ {isEditing ? ( + ( + field.onChange(next)} + accessToken={accessToken || ""} + placeholder="Select knowledge bases (optional)" + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.vector_store_ids ? ( + Array.isArray(localModelData.litellm_params.vector_store_ids) ? ( + localModelData.litellm_params.vector_store_ids.length > 0 ? ( +
+ {localModelData.litellm_params.vector_store_ids.map( + (vsId: string, index: number) => ( + + {vsId} + + ), + )} +
+ ) : ( + "No knowledge bases attached" + ) + ) : ( + String(localModelData.litellm_params.vector_store_ids) + ) + ) : ( + "Not Set" + )} +
+ )} +
+ +
+

Tags

+ {isEditing ? ( + ( + + )} + /> + ) : ( +
+ {localModelData.litellm_params?.tags ? ( + Array.isArray(localModelData.litellm_params.tags) ? ( + localModelData.litellm_params.tags.length > 0 ? ( +
+ {localModelData.litellm_params.tags.map( + (tag: string, index: number) => ( + + {tag} + + ), + )} +
+ ) : ( + "No tags assigned" + ) + ) : ( + localModelData.litellm_params.tags + ) + ) : ( + "Not Set" + )} +
+ )} +
+ +
+

Existing Credentials

+ {isEditing ? ( + ( + ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + placeholder="Select or search for existing credentials" + /> + )} + /> + ) : ( +
+ {localModelData.litellm_params?.litellm_credential_name || + "Manual"} +
+ )} +
+ + {isWildcardModel && ( +
+

Health Check Model

+ {isEditing ? ( + { + const wildcardProvider = + modelData.litellm_model_name.split("/")[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (modelHubData?.data || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((m: any) => + m.providers?.includes(wildcardProvider) && + m.model_group !== modelData.litellm_model_name, + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => ({ + value: m.model_group, + label: m.model_group, + })); + return ( + + ); + }} + /> + ) : ( +
+ {localModelData.model_info?.health_check_model || "Not Set"} +
+ )} +
+ )} + + {isEditing ? ( + setShowCacheControl(checked)} + /> + ) : ( +
+

Cache Control

+
+ {localModelData.litellm_params?.cache_control_injection_points ? ( +
+

Enabled

+
+ {localModelData.litellm_params.cache_control_injection_points.map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (point: any, i: number) => ( +
+ Location: {point.location}, + {point.role && Role: {point.role}} + {point.index !== undefined && ( + Index: {point.index} + )} +
+ ), + )} +
+
+ ) : ( + "Disabled" + )} +
+
+ )} + +
+

Model Info

+ {isEditing ? ( + ( +