mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate the model settings and credential rotation modals to react-hook-form and shadcn (#37342)
* refactor(ui): migrate the model settings and credential rotation modals to react-hook-form Both modals owned a self-contained antd FormInstance with no shared form children, so each migrates on its own without touching the add_model graph. ModelSettingsModal keeps its antd Modal shell, footer buttons and Skeleton placeholder. The single store_model_in_db field becomes a shadcn Switch inside a FormField, the antd Form.Item tooltip stays a hover tooltip rather than becoming always-visible description text, and the remount-on-new-config behaviour that the antd `key` provided is now RHF's `values` option. UpdateModelCredentialsModal keeps the antd Modal and warning Alert. The Input.Password becomes an InputGroup with an Eye/EyeOff reveal toggle so the reveal affordance survives, and the required rule ports to the same message. Both submit paths stay exactly as they were: Enter still submits here because the antd Form had onFinish and a real submit button, while the settings modal keeps submitting only from its footer button. * refactor(ui): reuse the shared PasswordInput in the credential rotation modal
This commit is contained in:
parent
5d1401342a
commit
76ff0d5351
3 changed files with 156 additions and 38 deletions
|
|
@ -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}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ 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<ModelSettingsModalProps> = ({ isVisible, onCa
|
|||
}, [isVisible, refetch]);
|
||||
|
||||
// Compute initial values from fetched config data
|
||||
const initialValues = useMemo(() => {
|
||||
const initialValues = useMemo<StoreModelInDBParams>(() => {
|
||||
if (!proxyConfigData) {
|
||||
return {
|
||||
store_model_in_db: false,
|
||||
|
|
@ -40,6 +55,8 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
|
|||
};
|
||||
}, [proxyConfigData]);
|
||||
|
||||
const form = useForm<StoreModelInDBParams>({ defaultValues: initialValues, values: initialValues });
|
||||
|
||||
const handleFormSubmit = async (formValues: StoreModelInDBParams) => {
|
||||
try {
|
||||
await mutateAsync(formValues, {
|
||||
|
|
@ -58,7 +75,7 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
|
|||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(initialValues);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
|
@ -71,32 +88,47 @@ const ModelSettingsModal: React.FC<ModelSettingsModalProps> = ({ isVisible, onCa
|
|||
<Button onClick={handleCancel} disabled={isPending || isLoadingConfig}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" loading={isPending} disabled={isLoadingConfig} onClick={() => form.submit()}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isPending}
|
||||
disabled={isLoadingConfig}
|
||||
onClick={() => void form.handleSubmit(handleFormSubmit)()}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form
|
||||
key={proxyConfigData ? JSON.stringify(initialValues) : "loading"}
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
onFinish={handleFormSubmit}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<Form.Item
|
||||
label="Store Model in DB"
|
||||
name="store_model_in_db"
|
||||
tooltip={
|
||||
proxyConfigData?.find((f) => 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 ? <Skeleton.Input active block /> : <Switch />}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="store_model_in_db"
|
||||
label={labelWithHint(
|
||||
"Store Model in DB",
|
||||
proxyConfigData?.find((f) => 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 ? (
|
||||
<Skeleton.Input active block />
|
||||
) : (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
onBlur={onBlur}
|
||||
className="w-fit"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof updateCredentialsSchema>;
|
||||
|
||||
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 (
|
||||
<Modal title="Update API Key" open={open} onCancel={close} footer={null} width={520} destroyOnHidden={true}>
|
||||
<Text className="block mb-4 text-gray-500">
|
||||
<Text className="block mb-4 text-muted-foreground">
|
||||
Update this model's API key. Only the new key is sent; the rest of the deployment configuration is left
|
||||
untouched.
|
||||
</Text>
|
||||
|
|
@ -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."
|
||||
/>
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
||||
<Form.Item label="New API Key" name="api_key" rules={[{ required: true, message: "Enter a new API key" }]}>
|
||||
<Input.Password placeholder="Enter the new API key" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<div className="flex justify-end items-center mt-4">
|
||||
<Button onClick={close} style={{ marginRight: 10 }}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="api_key" label="New API Key">
|
||||
{({ ref, ...field }) => (
|
||||
<PasswordInput {...field} ref={ref} placeholder="Enter the new API key" autoComplete="new-password" />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-end items-center mt-4 gap-2.5">
|
||||
<Button type="button" variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Update API Key
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue