From 5a899f596bf39f42fea3e2be2c8dc9addcedd588 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 23:43:54 -0700 Subject: [PATCH] refactor(ui): port the add model form off antd Form onto react-hook-form (#37446) * fix(ui): restore the cache control Role and Index field hints The add_model cache control editor lost both field hints when it moved off antd Form.Item in #37392. "LiteLLM will mark all messages of this role as cacheable" and "(Optional) If set litellm will mark the message at this index as cacheable" went with the Form.Item tooltip props and neither string exists in dashboard source any more. The Index hint was the only thing telling a user that field is optional, so this is lost information rather than styling. Both come back as shadcn tooltips beside their labels, matching how the surviving switch-level hint is already rendered. Also adds the payload characterization net this graph did not have. Before this commit the seven suites over add_model and model_add held 37 cases, no antd module mock, and zero toStrictEqual, so nothing pinned the submit payload. AddModelPanel.integration.test.tsx drives the real panel, the real antd store and the real prepareModelAddRequest, and asserts the object handed to modelCreateCall. It pins the distinctions only a strict assertion can see: litellm_credential_name arrives as null from its initialValue while api_key, api_base, mode and access_groups arrive as undefined, and team_id is absent entirely until the Team-BYOK switch mounts it. It also pins the mount gate in both directions, since a collapsed Advanced Settings drops both its keys and anything typed into it while re-expanding restores them, and the empty-string skip, since a cleared api_base must vanish rather than arrive as "". Every fixture was captured from the running component rather than written by hand. A 12-mutation battery over the bindings, the empty-string skip, the two required rules and an added keepMounted all go red, each run gated on having executed the expected case count. * refactor(ui): port the add model form off antd Form onto react-hook-form The Add Model form graph is shared by three antd hosts, so it only moves as one piece: AddModelPanel, LlmCredentialsPanel and CredentialModal all mount the same children. Form and Form.Item are replaced everywhere, and every widget inside them is left alone, so the change is the binding layer only. antd submits the mounted fields, react-hook-form submits its whole store. A shared mount registry keeps that difference from reaching the request: each field registers on mount, and the panel projects the store down to the registered names before it builds the payload. shouldUnregister would have been the other option, but it drops a collapsed section's typed values, so re-expanding Advanced Settings would come back empty. The antd rules modules are reused as-is through a thin validator adapter, so the messages stay in one place rather than being reworded per field. Advanced Settings held a Form.useForm() instance in a component that renders no Form, which made ten imperative calls dead. They are removed rather than translated, and the three behaviours they looked like they drove were checked against the antd original first: invalid LiteLLM Params still blocks submit, the pass-through toggle still leaves LiteLLM Params empty, and turning custom pricing off then on still keeps the typed cost. The existing 14 case payload net runs unedited against the port. * test(ui): pin the three add model behaviours the dead form instance looked like it drove Advanced Settings used to hold a form instance it never rendered, and the ten imperative calls against it were dead. The inherited payload net covered none of the three behaviours those calls appeared to own, so removing them looked riskier than it was. These cases characterise what the antd original actually did, checked against it before the port. Invalid LiteLLM Params blocks the submit, which also closes the one mutation the inherited net could not kill: dropping the JSON rule left all 14 green. --- ui/litellm-dashboard/eslint-suppressions.json | 34 +- .../panels/AddModelPanel.integration.test.tsx | 60 ++ .../panels/AddModelPanel.tsx | 41 +- .../panels/LlmCredentialsPanel.tsx | 13 +- .../add_model/AddModelForm.test.tsx | 26 +- .../src/components/add_model/AddModelForm.tsx | 582 ++++++++++-------- .../add_model/advanced_settings.test.tsx | 17 +- .../add_model/advanced_settings.tsx | 538 +++++++++------- .../conditional_public_model_name.test.tsx | 8 +- .../conditional_public_model_name.tsx | 105 ++-- .../add_model/litellm_model_name.test.tsx | 10 +- .../add_model/litellm_model_name.tsx | 144 +++-- .../provider_specific_fields.test.tsx | 38 +- .../add_model/provider_specific_fields.tsx | 73 ++- .../common_components/antdFormRules.ts | 44 ++ .../components/model_add/CredentialModal.tsx | 144 +++-- .../tests/mounted-form-host.tsx | 24 + 17 files changed, 1151 insertions(+), 750 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/antdFormRules.ts create mode 100644 ui/litellm-dashboard/tests/mounted-form-host.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b0a4d258ea8..c471355db68 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -793,16 +793,6 @@ "count": 1 } }, - "src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": { "prefer-const": { "count": 6 @@ -1740,7 +1730,7 @@ }, "src/components/add_model/AddModelForm.test.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/components/add_model/AddModelForm.tsx": { @@ -1751,7 +1741,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 + "count": 2 } }, "src/components/add_model/ClassificationMethodConfig.tsx": { @@ -1803,9 +1793,6 @@ }, "no-restricted-imports": { "count": 3 - }, - "prefer-const": { - "count": 2 } }, "src/components/add_model/auto_router_connection_test.tsx": { @@ -1818,11 +1805,6 @@ "count": 1 } }, - "src/components/add_model/conditional_public_model_name.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/add_model/conditional_public_model_name.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1847,11 +1829,6 @@ "count": 1 } }, - "src/components/add_model/litellm_model_name.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/add_model/litellm_model_name.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1871,11 +1848,6 @@ "count": 2 } }, - "src/components/add_model/provider_specific_fields.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/add_model/provider_specific_fields.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3011,4 +2983,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index c22ff7ff460..b762f006261 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -361,4 +361,64 @@ describe("AddModelPanel validation gates", () => { expect(modelCreateCall).not.toHaveBeenCalled(); }); + + it("blocks the submit when LiteLLM Params is not valid JSON", async () => { + mockPtuEnabled.mockReturnValue(false); + const { user, openAdvanced, fillRequired, submitExpectingRejection } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.type(screen.getByLabelText("LiteLLM Params"), "rpm: 7"); + await submitExpectingRejection("Please enter valid JSON"); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); +}); + +describe("AddModelPanel behaviours the removed Advanced Settings form instance never drove", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtuEnabled.mockReturnValue(false); + mockAuthorized.mockReturnValue(PROXY_ADMIN); + }); + + it("leaves LiteLLM Params untouched when pass through routes is switched on", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.click(screen.getByLabelText("Use in pass through routes")); + expect(screen.getByLabelText("LiteLLM Params")).toHaveValue(""); + + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted, ...advancedOpenExtras, use_in_pass_through: true }, + model_info: { ...baseModelInfo }, + }); + }); + + it("keeps a typed cost when custom pricing is switched off and back on", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.click(screen.getByLabelText("Custom Pricing")); + await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3"); + await user.click(screen.getByLabelText("Custom Pricing")); + await waitFor(() => expect(screen.queryByLabelText("Input Cost (per 1M tokens)")).not.toBeInTheDocument()); + await user.click(screen.getByLabelText("Custom Pricing")); + expect(await screen.findByLabelText("Input Cost (per 1M tokens)")).toHaveValue("3"); + + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { + ...alwaysMounted, + ...advancedOpenExtras, + input_cost_per_token: 0.000003, + cache_read_input_token_cost: 0.000003, + }, + model_info: { ...baseModelInfo }, + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 35be19531d5..59d4f95c038 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -1,21 +1,28 @@ "use client"; -import { Form } from "antd"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; import AddModelForm from "@/components/add_model/AddModelForm"; import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; +import { + projectMountedValues, + useMountRegistry, + type MountedFormValues, +} from "@/components/common_components/MountedFormField"; import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers"; -import { toast } from "@/lib/toast"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload"; +const INITIAL_VALUES: MountedFormValues = { litellm_credential_name: null }; + export default function AddModelPanel() { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); + const form = useForm({ mode: "onChange", defaultValues: INITIAL_VALUES }); + const registry = useMountRegistry(); const queryClient = useQueryClient(); const { data: modelCostMapData } = useModelCostMap(); const { data: credentialsResponse } = useCredentials(); @@ -26,28 +33,36 @@ export default function AddModelPanel() { const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - const handleOk = async () => { - try { - const values = await form.validateFields(); - await handleAddModelSubmit(values, accessToken, form, refresh); - } catch (error: any) { - const errorMessages = - error.errorFields?.map((field: any) => `${field.name.join(".")}: ${field.errors.join(", ")}`).join(" | ") || - "Unknown validation error"; - toast.fromError(`Please fill in the following required fields: ${errorMessages}`); + const mountedValues = () => projectMountedValues(registry, form.getValues); + + const handleOk = async (): Promise => { + const isValid = await form.trigger(registry.mountedNames() as string[]); + if (!isValid) { + return false; } + await handleAddModelSubmit( + mountedValues(), + accessToken, + { resetFields: () => form.reset(INITIAL_VALUES) }, + refresh, + ); + return true; }; return ( setProviderModels(getProviderModels(provider, modelCostMapData))} getPlaceholder={getPlaceholder} - uploadProps={vertexCredentialsUploadProps(form)} + uploadProps={vertexCredentialsUploadProps({ + setFieldsValue: (values) => form.setValue("vertex_credentials", values.vertex_credentials), + })} showAdvancedSettings={showAdvancedSettings} setShowAdvancedSettings={setShowAdvancedSettings} teams={teams ?? null} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx index 6112e6bffbd..7251da7c3c3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx @@ -1,10 +1,17 @@ "use client"; -import { Form } from "antd"; +import { useForm } from "react-hook-form"; import CredentialsPanel from "@/components/model_add/CredentialsPanel"; +import type { MountedFormValues } from "@/components/common_components/MountedFormField"; import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload"; export default function LlmCredentialsPanel() { - const [form] = Form.useForm(); - return ; + const form = useForm(); + return ( + form.setValue("vertex_credentials", values.vertex_credentials), + })} + /> + ); } diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx index 8a99d9eae68..26bd9af94a8 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx @@ -1,11 +1,12 @@ import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -import { Form } from "antd"; import type { UploadProps } from "antd/es/upload"; import { describe, expect, it, vi } from "vitest"; import type { Team } from "../key_team_helpers/key_list"; import type { CredentialItem } from "../networking"; import { Providers } from "../provider_info_helpers"; +import { projectMountedValues, useMountRegistry, type MountedFormValues } from "../common_components/MountedFormField"; +import { useForm } from "react-hook-form"; import AddModelForm from "./AddModelForm"; vi.mock("../molecules/models/ProviderLogo", () => ({ @@ -131,8 +132,12 @@ const testTeam: Team = { }; const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmin = false) => { - const { result } = renderHook(() => Form.useForm()); - const [form] = result.current; + const { result } = renderHook(() => { + const form = useForm({ mode: "onChange" }); + const registry = useMountRegistry(); + return { form, registry }; + }); + const { form, registry } = result.current; const teams = [ { @@ -159,7 +164,9 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi return { form, - handleOk: vi.fn(), + registry, + mountedValues: () => projectMountedValues(registry, form.getValues), + handleOk: vi.fn().mockResolvedValue(true), setSelectedProvider: vi.fn(), setProviderModelsFn: vi.fn(), getPlaceholder: vi.fn((provider: Providers) => `Enter ${provider} model name`), @@ -331,16 +338,7 @@ describe("AddModelForm", () => { await user.click(screen.getByLabelText("Cache Control Injection Points")); await waitFor(() => expect(screen.queryByText("Add Injection Point")).not.toBeInTheDocument()); }, - // AddModelPanel builds the wire payload from form.validateFields(), which reports exactly - // the mounted registered set. Reading the same instance the same way keeps this on the - // real payload path; a rejection still carries the same `values` object. - mountedValues: async (): Promise> => { - try { - return await props.form.validateFields(); - } catch (error) { - return (error as { values: Record }).values; - } - }, + mountedValues: async (): Promise> => props.mountedValues(), }; }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index de159207078..4d339737ab0 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -4,11 +4,20 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags"; import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { modelCreationScope } from "@/utils/modelPermissions"; import { Switch } from "@/components/ui/switch"; -import type { FormInstance } from "antd"; -import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd"; +import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Select as AntdSelect, Button, Card, Col, Modal, Row, Tooltip, Typography, Alert } from "antd"; import type { UploadProps } from "antd/es/upload"; import React, { useEffect, useMemo, useState } from "react"; +import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form"; import TeamDropdown from "../common_components/team_dropdown"; +import { antdRequired } from "../common_components/antdFormRules"; +import { labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { + MountedFormField, + MountedFormProvider, + type MountRegistry, + type MountedFormValues, +} from "../common_components/MountedFormField"; import type { Team } from "../key_team_helpers/key_list"; import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; import { Providers } from "../provider_info_helpers"; @@ -22,8 +31,10 @@ import { TEST_MODES } from "./add_model_modes"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface AddModelFormProps { - form: FormInstance; // For the Add Model tab - handleOk: () => Promise; + form: UseFormReturn; // For the Add Model tab + registry: MountRegistry; + mountedValues: () => MountedFormValues; + handleOk: () => Promise; selectedProvider: Providers; setSelectedProvider: (provider: Providers) => void; providerModels: string[]; @@ -36,10 +47,20 @@ interface AddModelFormProps { credentials: CredentialItem[]; } +const connectionTestModelName = (values: MountedFormValues): string | undefined => { + const named = values.model_name || values.model; + if (Array.isArray(named)) { + return named.join(", "); + } + return typeof named === "string" ? named : undefined; +}; + const { Title, Link } = Typography; const AddModelForm: React.FC = ({ form, + registry, + mountedValues, handleOk, selectedProvider, setSelectedProvider, @@ -67,6 +88,7 @@ const AddModelForm: React.FC = ({ const { data: guardrailsData } = useGuardrails(); const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name); const { data: tagsList } = useTags(); + const selectedCredentialName = useWatch({ control: form.control, name: "litellm_credential_name" }); const handleTestConnection = async () => { setIsTestingConnection(true); @@ -112,274 +134,302 @@ const AddModelForm: React.FC = ({ Add Model -
{ - await handleOk().then(() => { - setTeamAdminSelectedTeam(null); - }); - }} - onFinishFailed={(errorInfo) => {}} - labelCol={{ span: 10 }} - wrapperCol={{ span: 16 }} - labelAlign="left" - > - <> - {requiresTeamScope && ( - <> - - { - setTeamAdminSelectedTeam(value); - }} - /> - - {!teamAdminSelectedTeam && ( - - )} - - )} - {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( - <> - - { - setSelectedProvider(value as Providers); - setProviderModelsFn(value as Providers); - form.setFieldsValue({ - custom_llm_provider: value, - }); - form.setFieldsValue({ - model: [], - model_name: undefined, - }); - }} - > - {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( - - {providerMetadataErrorText} - - )} - {sortedProviderMetadata.map((providerInfo) => { - const displayName = providerInfo.provider_display_name; - const providerKey = providerInfo.provider; - - return ( - -
- - {displayName} -
-
- ); - })} -
-
- - - {/* Conditionally Render "Public Model Name" */} - - - {/* Select Mode */} - - setTestMode(value)} - options={TEST_MODES} - /> - - - - -

- Optional - LiteLLM endpoint to use when health checking this model{" "} - - Learn more - -

- -
- - {/* Credentials */} -
- - Either select existing credentials OR enter new provider credentials below - -
- - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - - - - prevValues.litellm_credential_name !== currentValues.litellm_credential_name || - prevValues.provider !== currentValues.provider + + + { + event.preventDefault(); + void handleOk().then((submitted) => { + if (submitted) { + setTeamAdminSelectedTeam(null); } - > - {({ getFieldValue }) => { - const credentialName = getFieldValue("litellm_credential_name"); - // Only show provider specific fields if no credentials selected - if (!credentialName) { - return ( - <> -
-
- OR -
-
- - - ); - } - return null; - }} -
-
-
- Additional Model Info Settings -
-
- {/* Team-only Model Switch - Only show for proxy admins, not team admins */} - {(isAdmin || !isTeamAdmin) && ( - - - - { - setIsTeamOnly(checked); - if (!checked) { - form.setFieldValue("team_id", undefined); - } - }} - disabled={!premiumUser} - aria-label="Team-BYOK Model" - /> - - - - )} - - {/* Conditional Team Selection */} - {isTeamOnly && !requiresTeamScope && ( - - - - )} - {isAdmin && ( + }); + }} + > + <> + {requiresTeamScope && ( <> - - ({ - value: group, - label: group, - }))} - maxTagCount="responsive" - allowClear + {(control) => ( + { + control.onChange(value); + setTeamAdminSelectedTeam(value); + }} + /> + )} + + {!teamAdminSelectedTeam && ( + - + )} )} - + {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( + <> + + {(control) => ( + { + control.onChange(value); + setSelectedProvider(value as Providers); + setProviderModelsFn(value as Providers); + form.setValue("model", []); + form.setValue("model_name", undefined); + }} + > + {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( + + {providerMetadataErrorText} + + )} + {sortedProviderMetadata.map((providerInfo) => { + const displayName = providerInfo.provider_display_name; + const providerKey = providerInfo.provider; + + return ( + +
+ + {displayName} +
+
+ ); + })} +
+ )} +
+ + + {/* Conditionally Render "Public Model Name" */} + + + {/* Select Mode */} + + {(control) => ( + { + control.onChange(value); + setTestMode(value); + }} + options={TEST_MODES} + /> + )} + + + + +

+ Optional - LiteLLM endpoint to use when health checking this model{" "} + + Learn more + +

+ +
+ + {/* Credentials */} +
+ + Either select existing credentials OR enter new provider credentials below + +
+ + + {(control) => ( + + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + value={control.value as string | null | undefined} + onChange={control.onChange} + onBlur={control.onBlur} + options={[ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + allowClear + /> + )} + + + {/* Only show provider specific fields if no credentials selected */} + {!selectedCredentialName && ( + <> +
+
+ OR +
+
+ + + )} +
+
+ Additional Model Info Settings +
+
+ {/* Team-only Model Switch - Only show for proxy admins, not team admins */} + {(isAdmin || !isTeamAdmin) && ( + + + {labelWithHint( + "Team-BYOK Model", + "Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.", + )} + + + + { + setIsTeamOnly(checked); + if (!checked) { + form.setValue("team_id", undefined); + } + }} + disabled={!premiumUser} + aria-label="Team-BYOK Model" + /> + + + + )} + + {/* Conditional Team Selection */} + {isTeamOnly && !requiresTeamScope && ( + + {(control) => ( + + )} + + )} + {isAdmin && ( + <> + + {(control) => ( + ({ + value: group, + label: group, + }))} + maxTagCount="responsive" + allowClear + /> + )} + + + )} + + + )} +
+ + Need Help? + +
+ + +
+
- )} -
- - Need Help? - -
- - -
-
- - + + +
{/* Test Connection Results Modal */} @@ -408,10 +458,10 @@ const AddModelForm: React.FC = ({ { setIsResultModalVisible(false); setIsTestingConnection(false); diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx index 4e5c5f25374..01e00d903aa 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MountedFormHost } from "../../../tests/mounted-form-host"; import AdvancedSettings from "./advanced_settings"; const mockUsePtuCostAttributionEnabled = vi.fn(); @@ -12,13 +13,15 @@ const PTU_LABELS = ["PTU Count", "Calculated Cost per PTU / Hour (USD)", "PTU Ef const renderAdvancedSettings = () => render( - {}} - guardrailsList={[]} - tagsList={{}} - accessToken="test-token" - />, + + {}} + guardrailsList={[]} + tagsList={{}} + accessToken="test-token" + /> + , ); describe("AdvancedSettings", () => { diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index ac3288faf8b..140b4363327 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Switch, Select, Tooltip, DatePicker } from "antd"; +import { Switch, Select, Tooltip, DatePicker } from "antd"; import { ChevronDown } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; @@ -7,6 +7,9 @@ import { Row, Col, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Team } from "../key_team_helpers/key_list"; +import { antdRules } from "../common_components/antdFormRules"; +import { labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { MountedFormField } from "../common_components/MountedFormField"; import CacheControlInjectionPoints, { CACHE_CONTROL_LABEL, CACHE_CONTROL_TOOLTIP, @@ -39,6 +42,31 @@ interface AdvancedSettingsProps { accessToken: string; } +const USAGE_COST_FIELDS = [ + "input_cost_per_token", + "output_cost_per_token", + "cache_read_input_token_cost", + "cache_creation_input_token_cost", + "input_cost_per_second", +]; + +const REVALIDATED_WHEN_PTU_COUNT_CHANGES = [PTU_RATE_FIELD, PTU_START_FIELD, ...USAGE_COST_FIELDS]; + +const validateNumber = (_: unknown, value: unknown) => { + if (!value) { + return Promise.resolve(); + } + if (isNaN(Number(value)) || Number(value) < 0) { + return Promise.reject("Please enter a valid positive number"); + } + return Promise.resolve(); +}; + +const usageCostRules = { + deps: [PTU_COUNT_FIELD], + validate: antdRules({ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)), +}; + const AdvancedSettings: React.FC = ({ showAdvancedSettings, setShowAdvancedSettings, @@ -47,95 +75,36 @@ const AdvancedSettings: React.FC = ({ tagsList, accessToken, }) => { - const [form] = Form.useForm(); const [customPricing, setCustomPricing] = React.useState(false); const [pricingModel, setPricingModel] = React.useState<"per_token" | "per_second">("per_token"); const [showCacheControl, setShowCacheControl] = React.useState(false); const ptuCostAttributionEnabled = usePtuCostAttributionEnabled(); - // Add validation function for numbers - const validateNumber = (_: any, value: string) => { - if (!value) { - return Promise.resolve(); - } - if (isNaN(Number(value)) || Number(value) < 0) { - return Promise.reject("Please enter a valid positive number"); - } - return Promise.resolve(); - }; - - // Handle custom pricing changes - const handleCustomPricingChange = (checked: boolean) => { - setCustomPricing(checked); - if (!checked) { - // Clear pricing fields when disabled - form.setFieldsValue({ - input_cost_per_token: undefined, - output_cost_per_token: undefined, - cache_read_input_token_cost: undefined, - cache_creation_input_token_cost: undefined, - input_cost_per_second: undefined, - }); - } - }; - - const handlePassThroughChange = (checked: boolean) => { - const currentParams = form.getFieldValue("litellm_extra_params"); - try { - let paramsObj = currentParams ? JSON.parse(currentParams) : {}; - if (checked) { - paramsObj.use_in_pass_through = true; - } else { - delete paramsObj.use_in_pass_through; - } - // Only set the field value if there are remaining parameters - if (Object.keys(paramsObj).length > 0) { - form.setFieldValue("litellm_extra_params", JSON.stringify(paramsObj, null, 2)); - } else { - form.setFieldValue("litellm_extra_params", ""); - } - } catch (error) { - // If JSON parsing fails, only create new object if checked is true - if (checked) { - form.setFieldValue("litellm_extra_params", JSON.stringify({ use_in_pass_through: true }, null, 2)); - } else { - form.setFieldValue("litellm_extra_params", ""); - } - } - }; - - const handleCacheControlChange = (checked: boolean) => { - setShowCacheControl(checked); - if (!checked) { - const currentParams = form.getFieldValue("litellm_extra_params"); - try { - let paramsObj = currentParams ? JSON.parse(currentParams) : {}; - delete paramsObj.cache_control_injection_points; - if (Object.keys(paramsObj).length > 0) { - form.setFieldValue("litellm_extra_params", JSON.stringify(paramsObj, null, 2)); - } else { - form.setFieldValue("litellm_extra_params", ""); - } - } catch (error) { - form.setFieldValue("litellm_extra_params", ""); - } - } - }; - return ( <> Advanced Settings - + -
- - - +
+ + {(control) => ( + { + control.onChange(checked); + setCustomPricing(checked); + }} + className="bg-gray-600" + /> + )} + - Attached Knowledge Bases (RAG){" "} @@ -151,18 +120,21 @@ const AdvancedSettings: React.FC = ({ } - name="vector_store_ids" className="mt-4" help="Select vector stores to attach. Requests to this model will automatically use these for RAG. Set up vector stores in Tools > Vector Stores." > - {}} - accessToken={accessToken} - placeholder="Select knowledge bases (optional)" - /> - + {(control) => ( + + )} + - Guardrails{" "} @@ -178,199 +150,331 @@ const AdvancedSettings: React.FC = ({ } - name="guardrails" className="mt-4" help="Select existing guardrails. Go to 'Guardrails' tab to create new guardrails." > - ({ value: name, label: name }))} + /> + )} + - - ({ + value: tag.name, + label: tag.name, + title: tag.description || tag.name, + }))} + /> + )} + {ptuCostAttributionEnabled && ( <> - - - + {(control) => ( + + )} + - - - + {(control) => ( + + )} + - - - + {(control) => ( + + )} + - - - + {(control) => ( + + )} + )} {customPricing && ( -
- - { + control.onChange(value); + setPricingModel(value); + }} + options={[ + { value: "per_token", label: "Per Million Tokens" }, + { value: "per_second", label: "Per Second" }, + ]} + /> + )} + {pricingModel === "per_token" ? ( <> - - - - ( + + )} + + - - - ( + + )} + + - - - ( + + )} + + - - + {(control) => ( + + )} + ) : ( - - - + {(control) => ( + + )} + )}
)} - Allow using these credentials in pass through routes.{" "} Learn more - - } + , + )} + className="mb-4 mt-4" > - - + {(control) => ( + + )} + - - - + {(control) => ( + { + control.onChange(checked); + setShowCacheControl(checked); + }} + className="bg-gray-600" + /> + )} + {showCacheControl && ( - - - + + {(control) => ( + ["value"]} + onChange={control.onChange} + /> + )} + )} - -