From b19d59be09b9d2095193feb0deca61731b3f310d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 17:40:49 -0700 Subject: [PATCH] refactor(ui): move the model info edit form off antd Form (#37392) * test(ui): characterize the model info and cache control submit payloads Pins the antd behaviour these forms have today, ahead of moving them onto react-hook-form: the full model info PATCH body, the sticky touched-field semantics that decide which pricing keys ship, the mounted-only cache control keys, and the string-typed injection point index. * 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. * refactor(ui): trim comments and type the model record prop on the edit form Cuts the explanatory comments that the house rules do not allow, keeping only the three that record non-obvious library behaviour plus the eslint directive, and narrows the modelData prop to the two fields the form reads. Corrects the claim in 5b7ecede4e that the cache control child registered the wrong key. At the staging tip the Form.List registration is on cache_control_injection_points and is live, which is why this PR rehosts it into advanced_settings. The dead part is the three getFieldValue("cache_control_points") readers, whose key nothing registers, so updateCacheControlPoints dereferences undefined and the caught error reaches the console on every role, index or remove change. --- ui/litellm-dashboard/eslint-suppressions.json | 15 - .../src/components/ModelInfoEditForm.tsx | 818 +++++++++++++++++ .../add_model/AddModelForm.test.tsx | 88 +- .../add_model/advanced_settings.tsx | 26 +- .../add_model/cache_control_settings.tsx | 260 +++--- .../src/components/model_info_view.test.tsx | 467 ++++++++++ .../src/components/model_info_view.tsx | 868 +----------------- .../src/components/shared/numerical_input.tsx | 21 +- .../src/utils/ptuValidation.ts | 43 +- 9 files changed, 1594 insertions(+), 1012 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 ca61a0c15af..dfaa417cae3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1816,12 +1816,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": { @@ -2276,15 +2270,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": 1 }, diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx new file mode 100644 index 00000000000..e58139c80a0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -0,0 +1,818 @@ +"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" }, +]; + +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; + } +}; + +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); + } + + 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; +}; + +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 the key must be absent, not 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: { model_info: { team_id?: string | null } & Record }; + 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, +}) => { + // Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change. + 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 every render, so this rebuild is what the next submit runs. + 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 }) => ( +