diff --git a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx index ba050b51c50..01696e2d820 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx @@ -4,8 +4,14 @@ import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/ import { StoreModelInDBParams, useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB"; import { toast } from "@/lib/toast"; import { parseErrorMessage } from "@/components/shared/errorUtils"; -import { Button, Form, Modal, Skeleton, Space, Switch, Typography } from "antd"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Button, Modal, Skeleton, Space, Typography } from "antd"; +import { CircleHelp } from "lucide-react"; import React, { useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; interface ModelSettingsModalProps { isVisible: boolean; @@ -13,8 +19,17 @@ interface ModelSettingsModalProps { onSuccess?: () => void; } +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + > +); + const ModelSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => { - const [form] = Form.useForm(); const { mutateAsync, isPending } = useStoreModelInDB(); const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS); @@ -26,7 +41,7 @@ const ModelSettingsModal: React.FC = ({ isVisible, onCa }, [isVisible, refetch]); // Compute initial values from fetched config data - const initialValues = useMemo(() => { + const initialValues = useMemo(() => { if (!proxyConfigData) { return { store_model_in_db: false, @@ -40,6 +55,8 @@ const ModelSettingsModal: React.FC = ({ isVisible, onCa }; }, [proxyConfigData]); + const form = useForm({ defaultValues: initialValues, values: initialValues }); + const handleFormSubmit = async (formValues: StoreModelInDBParams) => { try { await mutateAsync(formValues, { @@ -58,7 +75,7 @@ const ModelSettingsModal: React.FC = ({ isVisible, onCa }; const handleCancel = () => { - form.resetFields(); + form.reset(initialValues); onCancel(); }; @@ -71,32 +88,47 @@ const ModelSettingsModal: React.FC = ({ isVisible, onCa Cancel - form.submit()}> + void form.handleSubmit(handleFormSubmit)()} + > {isPending ? "Saving..." : "Save Settings"} } onCancel={handleCancel} > - - f.field_name === "store_model_in_db")?.field_description || - "If enabled, models and config are stored in and loaded from the database." - } - valuePropName="checked" - > - {isLoadingConfig ? : } - - + + event.preventDefault()}> + + f.field_name === "store_model_in_db")?.field_description || + "If enabled, models and config are stored in and loaded from the database.", + )} + > + {({ id, value, onChange, onBlur }) => + isLoadingConfig ? ( + + ) : ( + + ) + } + + + + ); }; diff --git a/ui/litellm-dashboard/src/components/update_model_credentials_modal.test.tsx b/ui/litellm-dashboard/src/components/update_model_credentials_modal.test.tsx index b21b768543c..9602432efcc 100644 --- a/ui/litellm-dashboard/src/components/update_model_credentials_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/update_model_credentials_modal.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import UpdateModelCredentialsModal from "./update_model_credentials_modal"; import * as networking from "./networking"; +import { toast } from "@/lib/toast"; vi.mock("./networking", async () => { const actual = await vi.importActual("./networking"); @@ -13,6 +14,7 @@ vi.mock("./networking", async () => { }); const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall); +const mockToast = vi.mocked(toast); beforeAll(() => { Object.defineProperty(window, "matchMedia", { @@ -76,4 +78,68 @@ describe("UpdateModelCredentialsModal", () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockModelPatchUpdateCall).not.toHaveBeenCalled(); }); + + it("renders the required message when the field is left blank", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByRole("button", { name: /update api key/i })); + + expect(await screen.findByText("Enter a new API key")).toBeInTheDocument(); + }); + + it("rejects a whitespace-only key without sending a PATCH", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText(/new api key/i), " "); + await user.click(screen.getByRole("button", { name: /update api key/i })); + + await waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Enter a new API key")); + expect(mockModelPatchUpdateCall).not.toHaveBeenCalled(); + }); + + it("trims surrounding whitespace off the key it sends", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText(/new api key/i), " sk-pad-77 "); + await user.click(screen.getByRole("button", { name: /update api key/i })); + + await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockModelPatchUpdateCall.mock.calls[0][1]).toEqual({ + litellm_params: { api_key: "sk-pad-77" }, + model_info: { id: "model-123" }, + }); + }); + + it("submits on Enter from inside the key field", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText(/new api key/i), "sk-enter-1{Enter}"); + + await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockModelPatchUpdateCall.mock.calls[0][1]).toEqual({ + litellm_params: { api_key: "sk-enter-1" }, + model_info: { id: "model-123" }, + }); + }); + + it("reveals and re-hides the key without touching the value", async () => { + const user = userEvent.setup(); + renderModal(); + + const field = screen.getByLabelText(/new api key/i); + await user.type(field, "sk-peek-42"); + expect(field).toHaveAttribute("type", "password"); + + await user.click(screen.getByRole("button", { name: /show password/i })); + expect(field).toHaveAttribute("type", "text"); + expect(field).toHaveValue("sk-peek-42"); + + await user.click(screen.getByRole("button", { name: /hide password/i })); + expect(field).toHaveAttribute("type", "password"); + expect(field).toHaveValue("sk-peek-42"); + }); }); diff --git a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx index d88efd64bc9..b0a19e7ea05 100644 --- a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx +++ b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx @@ -1,10 +1,25 @@ -import { Alert, Button, Form, Input, Modal, Typography } from "antd"; +import { Alert, Modal, Typography } from "antd"; import { useState } from "react"; +import { z } from "zod/v4"; import { modelPatchUpdateCall } from "./networking"; import { toast } from "@/lib/toast"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; const { Text } = Typography; +const updateCredentialsSchema = z.object({ + api_key: z.string().min(1, "Enter a new API key"), +}); + +type UpdateCredentialsValues = z.infer; + +const EMPTY_VALUES: UpdateCredentialsValues = { api_key: "" }; + interface UpdateModelCredentialsModalProps { open: boolean; onCancel: () => void; @@ -20,15 +35,15 @@ export default function UpdateModelCredentialsModal({ modelId, onUpdated, }: UpdateModelCredentialsModalProps) { - const [form] = Form.useForm(); + const form = useZodForm(updateCredentialsSchema, { defaultValues: EMPTY_VALUES }); const [isSaving, setIsSaving] = useState(false); const close = () => { - form.resetFields(); + form.reset(EMPTY_VALUES); onCancel(); }; - const handleSubmit = async (values: { api_key?: string }) => { + const handleSubmit = async (values: UpdateCredentialsValues) => { const apiKey = values.api_key?.trim(); if (!apiKey) { toast.fromError("Enter a new API key"); @@ -42,7 +57,7 @@ export default function UpdateModelCredentialsModal({ modelId, ); toast.success("API key updated"); - form.resetFields(); + form.reset(EMPTY_VALUES); onUpdated(); onCancel(); } catch (error) { @@ -55,7 +70,7 @@ export default function UpdateModelCredentialsModal({ return ( - + Update this model's API key. Only the new key is sent; the rest of the deployment configuration is left untouched. @@ -65,19 +80,24 @@ export default function UpdateModelCredentialsModal({ className="mb-4" message="Only the API key is rotated here. Models that authenticate with an Azure AD token, AWS credentials, or a Vertex service-account JSON aren't supported yet; update those from the model's LiteLLM Params for now." /> - - - - - - + + + + {({ ref, ...field }) => ( + + )} + + + + Cancel - + + {isSaving && } Update API Key - + ); }