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 }) => ( +