From 5b7ecede4ed8e93a19d95b7e3ab89e843e32d5bc Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 16:45:03 -0700 Subject: [PATCH] refactor(ui): move the model info edit form off antd Form The deployment edit form on the model info view now runs on react-hook-form with a zod resolver and shadcn controls, extracted into ModelInfoEditForm so the view keeps the payload builder and the form keeps the fields. Cache control injection points become a presentational value/onChange child, which lets the model info view host it through react-hook-form while the add model form keeps hosting it through antd. That child never wrote to a real store on either side: it registered under cache_control_points while both parents read cache_control_injection_points, so its form prop was inert. antd marks a field touched on change and never clears it, and neither touchedFields nor dirtyFields reproduces that, so the four pricing keys that gate on it track first change explicitly. The PTU rules move from antd validator wrappers to pure predicates that both surfaces share, since the add model form still feeds the wrappers to its own antd form. --- ui/litellm-dashboard/eslint-suppressions.json | 15 - .../src/components/ModelInfoEditForm.tsx | 835 +++++++++++++++++ .../add_model/AddModelForm.test.tsx | 15 +- .../add_model/advanced_settings.tsx | 26 +- .../add_model/cache_control_settings.tsx | 260 +++--- .../src/components/model_info_view.test.tsx | 308 ++++++- .../src/components/model_info_view.tsx | 856 +----------------- .../src/components/shared/numerical_input.tsx | 21 +- .../src/utils/ptuValidation.ts | 43 +- 9 files changed, 1362 insertions(+), 1017 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 9c475500b15..ca9bbb8bb27 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1913,12 +1913,6 @@ "src/components/add_model/cache_control_settings.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "prefer-const": { - "count": 1 } }, "src/components/add_model/conditional_public_model_name.test.tsx": { @@ -2405,15 +2399,6 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "max-lines": { - "count": 1 - }, - "no-nested-ternary": { - "count": 14 - }, "no-restricted-imports": { "count": 2 }, diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx new file mode 100644 index 00000000000..5d0847e30ce --- /dev/null +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -0,0 +1,835 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +// eslint-disable-next-line no-restricted-imports -- the dashboard has no shadcn date-time picker; the PTU window fields need one +import { DatePicker } from "antd"; +import { CircleHelp } from "lucide-react"; +import type { Dayjs } from "dayjs"; +import * as React from "react"; +import { useForm, type Resolver } from "react-hook-form"; +import { z } from "zod/v4"; + +import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; +import { FormField } from "@/components/shared/form/FormField"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; + +import CacheControlInjectionPoints, { + CACHE_CONTROL_LABEL, + CACHE_CONTROL_TOOLTIP, + type CacheControlInjectionPoint, +} from "./add_model/cache_control_settings"; +import type { CredentialItem } from "./networking"; +import NumericalInput from "./shared/numerical_input"; +import type { Tag } from "./tag_management/types"; +import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; +import { formatPtuUtcDisplay, utcIsoToPickerValue } from "../utils/ptuDatetime"; +import { isMaskedSecret } from "../utils/maskedSecretUtils"; +import { + MAX_COST_PER_PTU_PER_HOUR, + MAX_PTU_COUNT, + PTU_COUNT_FIELD, + PTU_END_FIELD, + PTU_RATE_FIELD, + PTU_START_FIELD, + isFilledPtuValue, + isNonNegativePtuRate, + isPositiveWholePtuCount, + ptuWindowIsOrdered, +} from "../utils/ptuValidation"; + +interface PtuEditField { + name: string; + label: string; + input: "number" | "datetime"; + placeholder?: string; + isCount?: boolean; +} + +const PTU_EDIT_FIELDS: PtuEditField[] = [ + { name: PTU_COUNT_FIELD, label: "PTU Count", input: "number", placeholder: "e.g. 15", isCount: true }, + { name: PTU_RATE_FIELD, label: "Cost per PTU / Hour (USD)", input: "number", placeholder: "e.g. 2.00" }, + { name: PTU_START_FIELD, label: "PTU Effective From (UTC)", input: "datetime" }, + { name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" }, +]; + +/** The four names whose payload branch antd gated on `form.isFieldTouched`. */ +export type TouchedPricingField = "input_cost" | "output_cost" | "cache_read_cost" | "cache_write_cost"; + +const PRICING_FIELDS: readonly TouchedPricingField[] = [ + "input_cost", + "output_cost", + "cache_read_cost", + "cache_write_cost", +] as const; + +const COST_SOURCES: Record = { + input_cost: { param: "input_cost_per_token", info: "input_cost_per_token" }, + output_cost: { param: "output_cost_per_token", info: "output_cost_per_token" }, + cache_read_cost: { param: "cache_read_input_token_cost", info: "cache_read_input_token_cost" }, + cache_write_cost: { param: "cache_creation_input_token_cost", info: "cache_creation_input_token_cost" }, +}; + +export interface ModelEditFormValues { + model_name?: string; + litellm_model_name?: string; + api_base?: string; + custom_llm_provider?: string; + organization?: string; + tpm?: string | number | null; + rpm?: string | number | null; + max_retries?: string | number | null; + timeout?: string | number | null; + stream_timeout?: string | number | null; + input_cost?: string | number | null; + output_cost?: string | number | null; + cache_read_cost?: string | number | null; + cache_write_cost?: string | number | null; + ptu_count?: string | number | null; + cost_per_ptu_per_hour?: string | number | null; + ptu_effective_from?: Dayjs | null; + ptu_effective_to?: Dayjs | null; + cache_control?: boolean; + cache_control_injection_points?: CacheControlInjectionPoint[]; + model_access_group?: string[]; + guardrails?: string[]; + vector_store_ids?: string[]; + tags?: string[]; + health_check_model?: string | null; + litellm_credential_name?: string; + litellm_extra_params?: string; + model_info?: string; +} + +type ModelEditFieldName = keyof ModelEditFormValues; + +const scalar = z.union([z.string(), z.number(), z.null()]).optional(); +const textish = z.string().optional(); + +const modelEditShape = { + model_name: textish, + litellm_model_name: textish, + api_base: textish, + custom_llm_provider: textish, + organization: textish, + tpm: scalar, + rpm: scalar, + max_retries: scalar, + timeout: scalar, + stream_timeout: scalar, + input_cost: scalar, + output_cost: scalar, + cache_read_cost: scalar, + cache_write_cost: scalar, + ptu_count: scalar, + cost_per_ptu_per_hour: scalar, + ptu_effective_from: z.custom().nullish(), + ptu_effective_to: z.custom().nullish(), + cache_control: z.boolean().optional(), + cache_control_injection_points: z.array(z.custom()).optional(), + model_access_group: z.array(z.string()).optional(), + guardrails: z.array(z.string()).optional(), + vector_store_ids: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + health_check_model: z.string().nullish(), + litellm_credential_name: textish, + litellm_extra_params: textish, + model_info: textish, +}; + +const isJson = (value: string): boolean => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +/** + * Mirrors the antd rules `Form.Item` applied to the same fields. The predicates stay on + * `utils/ptuValidation` because `advanced_settings` still feeds the antd wrappers to its own + * form, so the two surfaces cannot drift apart on what the backend accepts. + */ +const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricingField) => boolean) => + z.object(modelEditShape).superRefine((values, ctx) => { + const reject = (path: ModelEditFieldName, message: string) => + ctx.addIssue({ code: "custom", path: [path], message }); + + if (values.litellm_extra_params && !isJson(values.litellm_extra_params)) { + reject("litellm_extra_params", "Please enter valid JSON"); + } + + // antd validates only mounted fields, and the PTU block does not render when the flag is off. + if (!ptuEnabled) { + return; + } + + if (!isPositiveWholePtuCount(values.ptu_count)) { + reject("ptu_count", `PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`); + } + if (!isNonNegativePtuRate(values.cost_per_ptu_per_hour)) { + reject( + "cost_per_ptu_per_hour", + `Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`, + ); + } + if (isFilledPtuValue(values.ptu_count) !== isFilledPtuValue(values.cost_per_ptu_per_hour)) { + const message = "PTU Count and Cost per PTU / Hour must be set together"; + reject("ptu_count", message); + reject("cost_per_ptu_per_hour", message); + } + if (isFilledPtuValue(values.ptu_count) && !isFilledPtuValue(values.ptu_effective_from)) { + reject("ptu_effective_from", "PTU Effective From is required when PTU Count is set"); + } + if (!ptuWindowIsOrdered(values.ptu_effective_from, values.ptu_effective_to)) { + const message = "PTU Effective To must be after PTU Effective From"; + reject("ptu_effective_from", message); + reject("ptu_effective_to", message); + } + + // A cost the operator never typed was echoed back from /model/info, which for an unpriced + // deployment is the public cost map. Refusing it would block putting an existing deployment + // on PTU, and the save omits an untouched cost anyway. + for (const field of PRICING_FIELDS) { + const value = values[field]; + if ( + isFieldTouched(field) && + isFilledPtuValue(values.ptu_count) && + isFilledPtuValue(value) && + Number(value) !== 0 + ) { + reject(field, "A PTU deployment bills by reserved capacity, so this cost must be 0 or blank"); + } + } + }); + +const perMillionTokens = (...rates: (number | null | undefined)[]): number | null => { + const rate = rates.find((candidate) => candidate != null); + return rate == null ? null : rate * 1_000_000; +}; + +/** + * The named counterpart of the antd `initialValues` block: it lists every bound field rather than + * spreading the loaded record, so server-only keys never reach the payload. + */ +export const toModelEditFormValues = (localModelData: any, isWildcardModel: boolean): ModelEditFormValues => ({ + model_name: localModelData.model_name, + litellm_model_name: localModelData.litellm_model_name, + api_base: localModelData.litellm_params.api_base, + custom_llm_provider: localModelData.litellm_params.custom_llm_provider, + organization: localModelData.litellm_params.organization, + tpm: localModelData.litellm_params.tpm, + rpm: localModelData.litellm_params.rpm, + max_retries: localModelData.litellm_params.max_retries, + timeout: localModelData.litellm_params.timeout, + stream_timeout: localModelData.litellm_params.stream_timeout, + input_cost: perMillionTokens( + localModelData.litellm_params.input_cost_per_token, + localModelData.model_info?.input_cost_per_token, + ), + output_cost: perMillionTokens( + localModelData.litellm_params?.output_cost_per_token, + localModelData.model_info?.output_cost_per_token, + ), + ptu_count: localModelData.model_info?.ptu_count ?? null, + cost_per_ptu_per_hour: localModelData.model_info?.cost_per_ptu_per_hour ?? null, + ptu_effective_from: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_from), + ptu_effective_to: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_to), + cache_read_cost: perMillionTokens( + localModelData.litellm_params?.cache_read_input_token_cost, + localModelData.model_info?.cache_read_input_token_cost, + ), + cache_write_cost: perMillionTokens( + localModelData.litellm_params?.cache_creation_input_token_cost, + localModelData.model_info?.cache_creation_input_token_cost, + ), + cache_control: localModelData.litellm_params?.cache_control_injection_points ? true : false, + cache_control_injection_points: localModelData.litellm_params?.cache_control_injection_points || [], + model_access_group: Array.isArray(localModelData.model_info?.access_groups) + ? localModelData.model_info.access_groups + : [], + guardrails: Array.isArray(localModelData.litellm_params?.guardrails) ? localModelData.litellm_params.guardrails : [], + 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 + : undefined, + tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [], + // antd never mounted this field for a non-wildcard model, so its key never reached the + // payload. RHF submits the whole store, so the key has to be absent rather than null. + ...(isWildcardModel ? { health_check_model: localModelData.model_info?.health_check_model } : {}), + litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "", + litellm_extra_params: JSON.stringify( + Object.fromEntries( + Object.entries(localModelData.litellm_params || {}).filter( + ([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value), + ), + ), + null, + 2, + ), +}); + +const displayCost = (localModelData: any, field: TouchedPricingField): string => { + const { param, info } = COST_SOURCES[field]; + const rate = localModelData?.litellm_params?.[param] ?? localModelData?.model_info?.[info]; + return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set"; +}; + +interface ModelInfoEditFormProps { + localModelData: any; + modelData: any; + accessToken: string | null; + isEditing: boolean; + isSaving: boolean; + isWildcardModel: boolean; + ptuCostAttributionEnabled: boolean; + showCacheControl: boolean; + setShowCacheControl: (checked: boolean) => void; + onCancel: () => void; + onSubmit: (values: ModelEditFormValues, isFieldTouched: (field: TouchedPricingField) => boolean) => Promise; + modelAccessGroups: string[] | null; + guardrailsList: string[]; + tagsList: Record; + credentialsList: CredentialItem[]; + healthCheckModelOptions: { value: string; label: string }[]; +} + +const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +const FieldLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); + +const Hint: React.FC<{ text: string }> = ({ text }) => ( + + } + /> + {text} + +); + +const DocsHint: React.FC<{ text: string; href: string }> = ({ text, href }) => ( + event.stopPropagation()}> + + +); + +const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, emptyLabel }) => { + if (!values) { + return <>Not Set; + } + if (!Array.isArray(values)) { + return <>{String(values)}; + } + if (values.length === 0) { + return <>{emptyLabel}; + } + return ( +
+ {values.map((entry: string, index: number) => ( + + {entry} + + ))} +
+ ); +}; + +const ModelInfoEditForm: React.FC = ({ + localModelData, + modelData, + accessToken, + isEditing, + isSaving, + isWildcardModel, + ptuCostAttributionEnabled, + showCacheControl, + setShowCacheControl, + onCancel, + onSubmit, + modelAccessGroups, + guardrailsList, + tagsList, + credentialsList, + healthCheckModelOptions, +}) => { + // antd marks a field touched on CHANGE and never clears it. RHF's touchedFields is blur-based + // and dirtyFields resets when a value returns to its default, so neither reproduces the branch + // `handleModelUpdate` takes; this tracks first change the way rc-field-form does. + const touchedRef = React.useRef>(new Set()); + const isFieldTouched = React.useCallback((field: TouchedPricingField) => touchedRef.current.has(field), []); + const markTouched = (field: string) => { + touchedRef.current = new Set([...touchedRef.current, field]); + }; + + // react-hook-form refreshes control._options on every render, so a resolver rebuilt here is the + // one that runs on the next submit; the PTU flag needs no ref to stay current. + const resolver: Resolver = (values, context, options) => + zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched))(values, context, options); + + const form = useForm({ + resolver, + defaultValues: toModelEditFormValues(localModelData, isWildcardModel), + }); + + const submit = (event: React.FormEvent) => + form.handleSubmit(async (values) => { + await onSubmit(values, isFieldTouched); + })(event); + + const cancel = () => { + form.reset(toModelEditFormValues(localModelData, isWildcardModel)); + touchedRef.current = new Set(); + onCancel(); + }; + + const textField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => ( +
+ {label} + {isEditing ? ( + + {({ value, ...control }) => } + + ) : ( + {(stored as string) || "Not Set"} + )} +
+ ); + + const numberField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => ( +
+ {label} + {isEditing ? ( + + {({ value, ...control }) => } + + ) : ( + {(stored as string) || "Not Set"} + )} +
+ ); + + const pricingField = (name: TouchedPricingField, label: string, placeholder: string, description?: string) => ( +
+ {label} + {isEditing ? ( + + {({ value, onChange, ...control }) => ( + ) => { + markTouched(name); + onChange(event); + }} + /> + )} + + ) : ( + {displayCost(localModelData, name)} + )} +
+ ); + + const tagsField = ( + name: "model_access_group" | "guardrails" | "tags", + options: { value: string; label: string }[], + placeholder: string, + ) => ( + + {({ id, value, onChange }) => ( + + )} + + ); + + return ( + +
+
+
+ {textField("model_name", "Model Name", "Enter model name", localModelData.model_name)} + {textField( + "litellm_model_name", + "LiteLLM Model Name", + "Enter LiteLLM model name", + localModelData.litellm_model_name, + )} + + {pricingField("input_cost", "Input Cost (per 1M tokens)", "Enter input cost")} + {pricingField("output_cost", "Output Cost (per 1M tokens)", "Enter output cost")} + + {ptuCostAttributionEnabled && + PTU_EDIT_FIELDS.map((ptuField) => ( +
+ {ptuField.label} + {isEditing ? ( + + {({ value, onChange, ...control }) => + ptuField.input === "number" ? ( + + ) : ( + + ) + } + + ) : ( + + {(ptuField.input === "datetime" + ? formatPtuUtcDisplay(localModelData?.model_info?.[ptuField.name]) + : localModelData?.model_info?.[ptuField.name]) ?? "Not Set"} + + )} +
+ ))} + + {pricingField( + "cache_read_cost", + "Cache Read Cost (per 1M tokens)", + "Defaults to Input Cost if blank", + "If left blank on save, defaults to Input Cost.", + )} + {pricingField( + "cache_write_cost", + "Cache Write Cost (per 1M tokens)", + "Defaults to Input Cost if blank", + "If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).", + )} + + {textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)} + {textField( + "custom_llm_provider", + "Custom LLM Provider", + "Enter custom LLM provider", + localModelData.litellm_params?.custom_llm_provider, + )} + {textField( + "organization", + "Organization", + "Enter organization", + localModelData.litellm_params?.organization, + )} + + {numberField("tpm", "TPM (Tokens per Minute)", "Enter TPM", localModelData.litellm_params?.tpm)} + {numberField("rpm", "RPM (Requests per Minute)", "Enter RPM", localModelData.litellm_params?.rpm)} + {numberField("max_retries", "Max Retries", "Enter max retries", localModelData.litellm_params?.max_retries)} + {numberField("timeout", "Timeout (seconds)", "Enter timeout", localModelData.litellm_params?.timeout)} + {numberField( + "stream_timeout", + "Stream Timeout (seconds)", + "Enter stream timeout", + localModelData.litellm_params?.stream_timeout, + )} + +
+ Model Access Groups + {isEditing ? ( + tagsField( + "model_access_group", + (modelAccessGroups ?? []).map((group) => ({ value: group, label: group })), + "Select existing groups or type to create new ones", + ) + ) : ( + + + + )} +
+ +
+ + Guardrails + + + {isEditing ? ( + tagsField( + "guardrails", + guardrailsList.map((name) => ({ value: name, label: name })), + "Select existing guardrails or type to create new ones", + ) + ) : ( + + + + )} +
+ +
+ + Attached Knowledge Bases (RAG) + + + {isEditing ? ( + + {({ value, onChange }) => ( + + )} + + ) : ( + + + + )} +
+ +
+ Tags + {isEditing ? ( + tagsField( + "tags", + Object.values(tagsList).map((tag: Tag) => ({ value: tag.name, label: tag.name })), + "Select existing tags or type to create new ones", + ) + ) : ( + + + + )} +
+ +
+ Existing Credentials + {isEditing ? ( + + {({ id, value, onChange, onBlur }) => { + const items = [ + { value: "", label: "None" }, + ...credentialsList.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]; + return ( + + ); + }} + + ) : ( + {localModelData.litellm_params?.litellm_credential_name || "Manual"} + )} +
+ + {isWildcardModel && ( +
+ Health Check Model + {isEditing ? ( + + {({ id, value, onChange, onBlur }) => ( + + )} + + ) : ( + {localModelData.model_info?.health_check_model || "Not Set"} + )} +
+ )} + + {isEditing ? ( + <> + + {CACHE_CONTROL_LABEL} + + + } + orientation="horizontal" + > + {({ id, value, onChange, onBlur }) => ( + { + onChange(checked); + setShowCacheControl(checked); + }} + /> + )} + + {showCacheControl && ( + + {({ value, onChange }) => ( + + )} + + )} + + ) : ( +
+ Cache Control + + {localModelData.litellm_params?.cache_control_injection_points ? ( +
+

Enabled

+
+ {localModelData.litellm_params.cache_control_injection_points.map((point: any, i: number) => ( +
+ Location: {point.location},{point.role && Role: {point.role}} + {point.index !== undefined && Index: {point.index}} +
+ ))} +
+
+ ) : ( + "Disabled" + )} +
+
+ )} + +
+ Model Info + {isEditing ? ( + + {({ value, ...control }) => ( +