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.
This commit is contained in:
yuneng-jiang 2026-08-18 23:43:54 -07:00 committed by GitHub
parent 963c7fb0d4
commit 5a899f596b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1151 additions and 750 deletions

View file

@ -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
}
}
}
}

View file

@ -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 },
});
});
});

View file

@ -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<MountedFormValues>({ 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<boolean> => {
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 (
<AddModelForm
form={form}
registry={registry}
mountedValues={mountedValues}
handleOk={handleOk}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
providerModels={providerModels}
setProviderModelsFn={(provider) => 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}

View file

@ -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 <CredentialsPanel uploadProps={vertexCredentialsUploadProps(form)} />;
const form = useForm<MountedFormValues>();
return (
<CredentialsPanel
uploadProps={vertexCredentialsUploadProps({
setFieldsValue: (values) => form.setValue("vertex_credentials", values.vertex_credentials),
})}
/>
);
}

View file

@ -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<MountedFormValues>({ 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<Record<string, unknown>> => {
try {
return await props.form.validateFields();
} catch (error) {
return (error as { values: Record<string, unknown> }).values;
}
},
mountedValues: async (): Promise<Record<string, unknown>> => props.mountedValues(),
};
};

View file

@ -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<void>;
form: UseFormReturn<MountedFormValues>; // For the Add Model tab
registry: MountRegistry;
mountedValues: () => MountedFormValues;
handleOk: () => Promise<boolean>;
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<AddModelFormProps> = ({
form,
registry,
mountedValues,
handleOk,
selectedProvider,
setSelectedProvider,
@ -67,6 +88,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
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<AddModelFormProps> = ({
<Title level={2}>Add Model</Title>
<Card>
<Form
form={form}
onFinish={async (values) => {
await handleOk().then(() => {
setTeamAdminSelectedTeam(null);
});
}}
onFinishFailed={(errorInfo) => {}}
labelCol={{ span: 10 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
{requiresTeamScope && (
<>
<Form.Item
label="Select Team"
name="team_id"
rules={[{ required: true, message: "Please select a team to continue" }]}
tooltip="Select the team for which you want to add this model"
>
<TeamDropdown
onChange={(value) => {
setTeamAdminSelectedTeam(value);
}}
/>
</Form.Item>
{!teamAdminSelectedTeam && (
<Alert
message="Team Selection Required"
description="As a team admin, you need to select your team first before adding models."
type="info"
showIcon
className="mb-4"
/>
)}
</>
)}
{(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && (
<>
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="Provider:"
name="custom_llm_provider"
tooltip="E.g. OpenAI, Azure OpenAI, Anthropic, Bedrock, etc."
labelCol={{ span: 10 }}
labelAlign="left"
>
<AntdSelect
virtual={false}
showSearch
loading={isProviderMetadataLoading}
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
optionFilterProp="data-label"
onChange={(value) => {
setSelectedProvider(value as Providers);
setProviderModelsFn(value as Providers);
form.setFieldsValue({
custom_llm_provider: value,
});
form.setFieldsValue({
model: [],
model_name: undefined,
});
}}
>
{providerMetadataErrorText && sortedProviderMetadata.length === 0 && (
<AntdSelect.Option key="__error" value="">
{providerMetadataErrorText}
</AntdSelect.Option>
)}
{sortedProviderMetadata.map((providerInfo) => {
const displayName = providerInfo.provider_display_name;
const providerKey = providerInfo.provider;
return (
<AntdSelect.Option key={providerKey} value={providerKey} data-label={displayName}>
<div className="flex items-center space-x-2">
<ProviderLogo provider={providerKey} className="w-5 h-5" />
<span>{displayName}</span>
</div>
</AntdSelect.Option>
);
})}
</AntdSelect>
</Form.Item>
<LiteLLMModelNameField
selectedProvider={selectedProvider}
providerModels={providerModels}
getPlaceholder={getPlaceholder}
/>
{/* Conditionally Render "Public Model Name" */}
<ConditionalPublicModelName />
{/* Select Mode */}
<Form.Item label="Mode" name="mode" className="mb-1">
<AntdSelect
style={{ width: "100%" }}
value={testMode}
onChange={(value) => setTestMode(value)}
options={TEST_MODES}
/>
</Form.Item>
<Row>
<Col span={10}></Col>
<Col span={10}>
<p className="text-sm mb-5 mt-1">
<strong>Optional</strong> - LiteLLM endpoint to use when health checking this model{" "}
<Link href="https://docs.litellm.ai/docs/proxy/health#health" target="_blank">
Learn more
</Link>
</p>
</Col>
</Row>
{/* Credentials */}
<div className="mb-4">
<Typography.Text className="text-sm text-gray-500 mb-2">
Either select existing credentials OR enter new provider credentials below
</Typography.Text>
</div>
<Form.Item label="Existing Credentials" name="litellm_credential_name" initialValue={null}>
<AntdSelect
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.litellm_credential_name !== currentValues.litellm_credential_name ||
prevValues.provider !== currentValues.provider
<FormProvider {...form}>
<MountedFormProvider value={{ control: form.control, registry }}>
<form
onSubmit={(event) => {
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 (
<>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">OR</span>
<div className="grow border-t border-gray-200"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
</>
);
}
return null;
}}
</Form.Item>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">Additional Model Info Settings</span>
<div className="grow border-t border-gray-200"></div>
</div>
{/* Team-only Model Switch - Only show for proxy admins, not team admins */}
{(isAdmin || !isTeamAdmin) && (
<Form.Item
label="Team-BYOK Model"
tooltip="Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys."
className="mb-4"
>
<Tooltip
title={
!premiumUser
? "This is an enterprise-only feature. Upgrade to premium to restrict model+credential combinations to a specific team."
: ""
}
placement="top"
>
<span className="inline-flex">
<Switch
checked={isTeamOnly}
onCheckedChange={(checked) => {
setIsTeamOnly(checked);
if (!checked) {
form.setFieldValue("team_id", undefined);
}
}}
disabled={!premiumUser}
aria-label="Team-BYOK Model"
/>
</span>
</Tooltip>
</Form.Item>
)}
{/* Conditional Team Selection */}
{isTeamOnly && !requiresTeamScope && (
<Form.Item
label="Select Team"
name="team_id"
className="mb-4"
tooltip="Only keys for this team will be able to call this model."
rules={[
{
required: isTeamOnly && !isAdmin,
message: "Please select a team.",
},
]}
>
<TeamDropdown disabled={!premiumUser} />
</Form.Item>
)}
{isAdmin && (
});
}}
>
<>
{requiresTeamScope && (
<>
<Form.Item
label="Model Access Group"
name="model_access_group"
<MountedFormField
label={labelWithHint("Select Team", "Select the team for which you want to add this model")}
name="team_id"
required
rules={{ validate: { required: antdRequired("Please select a team to continue") } }}
className="mb-4"
tooltip="Use model access groups to give users access to select models, and add new ones to the group over time."
>
<AntdSelect
mode="tags"
showSearch
placeholder="Select existing groups or type to create new ones"
optionFilterProp="children"
tokenSeparators={[","]}
options={modelAccessGroups.map((group) => ({
value: group,
label: group,
}))}
maxTagCount="responsive"
allowClear
{(control) => (
<TeamDropdown
value={control.value as string | undefined}
onChange={(value) => {
control.onChange(value);
setTeamAdminSelectedTeam(value);
}}
/>
)}
</MountedFormField>
{!teamAdminSelectedTeam && (
<Alert
message="Team Selection Required"
description="As a team admin, you need to select your team first before adding models."
type="info"
showIcon
className="mb-4"
/>
</Form.Item>
)}
</>
)}
<AdvancedSettings
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams}
guardrailsList={guardrailsList || []}
tagsList={tagsList || {}}
accessToken={accessToken || ""}
/>
{(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && (
<>
<MountedFormField
label={labelWithHint("Provider", "E.g. OpenAI, Azure OpenAI, Anthropic, Bedrock, etc.")}
name="custom_llm_provider"
required
rules={{ validate: { required: antdRequired("Required") } }}
className="mb-4"
>
{(control) => (
<AntdSelect
id={control.id}
virtual={false}
showSearch
loading={isProviderMetadataLoading}
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
optionFilterProp="data-label"
value={control.value as string | undefined}
onBlur={control.onBlur}
onChange={(value) => {
control.onChange(value);
setSelectedProvider(value as Providers);
setProviderModelsFn(value as Providers);
form.setValue("model", []);
form.setValue("model_name", undefined);
}}
>
{providerMetadataErrorText && sortedProviderMetadata.length === 0 && (
<AntdSelect.Option key="__error" value="">
{providerMetadataErrorText}
</AntdSelect.Option>
)}
{sortedProviderMetadata.map((providerInfo) => {
const displayName = providerInfo.provider_display_name;
const providerKey = providerInfo.provider;
return (
<AntdSelect.Option key={providerKey} value={providerKey} data-label={displayName}>
<div className="flex items-center space-x-2">
<ProviderLogo provider={providerKey} className="w-5 h-5" />
<span>{displayName}</span>
</div>
</AntdSelect.Option>
);
})}
</AntdSelect>
)}
</MountedFormField>
<LiteLLMModelNameField
selectedProvider={selectedProvider}
providerModels={providerModels}
getPlaceholder={getPlaceholder}
/>
{/* Conditionally Render "Public Model Name" */}
<ConditionalPublicModelName />
{/* Select Mode */}
<MountedFormField label="Mode" name="mode" className="mb-1">
{(control) => (
<AntdSelect
id={control.id}
style={{ width: "100%" }}
value={control.value as string | undefined}
onBlur={control.onBlur}
onChange={(value) => {
control.onChange(value);
setTestMode(value);
}}
options={TEST_MODES}
/>
)}
</MountedFormField>
<Row>
<Col span={10}></Col>
<Col span={10}>
<p className="text-sm mb-5 mt-1">
<strong>Optional</strong> - LiteLLM endpoint to use when health checking this model{" "}
<Link href="https://docs.litellm.ai/docs/proxy/health#health" target="_blank">
Learn more
</Link>
</p>
</Col>
</Row>
{/* Credentials */}
<div className="mb-4">
<Typography.Text className="text-sm text-muted-foreground mb-2">
Either select existing credentials OR enter new provider credentials below
</Typography.Text>
</div>
<MountedFormField
label="Existing Credentials"
name="litellm_credential_name"
defaultValue={null}
className="mb-4"
>
{(control) => (
<AntdSelect
id={control.id}
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) =>
(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
/>
)}
</MountedFormField>
{/* Only show provider specific fields if no credentials selected */}
{!selectedCredentialName && (
<>
<div className="flex items-center my-4">
<div className="grow border-t border-border"></div>
<span className="px-4 text-muted-foreground text-sm">OR</span>
<div className="grow border-t border-border"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
</>
)}
<div className="flex items-center my-4">
<div className="grow border-t border-border"></div>
<span className="px-4 text-muted-foreground text-sm">Additional Model Info Settings</span>
<div className="grow border-t border-border"></div>
</div>
{/* Team-only Model Switch - Only show for proxy admins, not team admins */}
{(isAdmin || !isTeamAdmin) && (
<Field className="mb-4">
<FieldLabel>
{labelWithHint(
"Team-BYOK Model",
"Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.",
)}
</FieldLabel>
<Tooltip
title={
!premiumUser
? "This is an enterprise-only feature. Upgrade to premium to restrict model+credential combinations to a specific team."
: ""
}
placement="top"
>
<span className="inline-flex">
<Switch
checked={isTeamOnly}
onCheckedChange={(checked) => {
setIsTeamOnly(checked);
if (!checked) {
form.setValue("team_id", undefined);
}
}}
disabled={!premiumUser}
aria-label="Team-BYOK Model"
/>
</span>
</Tooltip>
</Field>
)}
{/* Conditional Team Selection */}
{isTeamOnly && !requiresTeamScope && (
<MountedFormField
label={labelWithHint("Select Team", "Only keys for this team will be able to call this model.")}
name="team_id"
className="mb-4"
required={isTeamOnly && !isAdmin}
rules={
isTeamOnly && !isAdmin
? { validate: { required: antdRequired("Please select a team.") } }
: undefined
}
>
{(control) => (
<TeamDropdown
value={control.value as string | undefined}
onChange={control.onChange}
disabled={!premiumUser}
/>
)}
</MountedFormField>
)}
{isAdmin && (
<>
<MountedFormField
label={labelWithHint(
"Model Access Group",
"Use model access groups to give users access to select models, and add new ones to the group over time.",
)}
name="model_access_group"
className="mb-4"
>
{(control) => (
<AntdSelect
id={control.id}
mode="tags"
showSearch
placeholder="Select existing groups or type to create new ones"
optionFilterProp="children"
tokenSeparators={[","]}
value={control.value as string[] | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
options={modelAccessGroups.map((group) => ({
value: group,
label: group,
}))}
maxTagCount="responsive"
allowClear
/>
)}
</MountedFormField>
</>
)}
<AdvancedSettings
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams}
guardrailsList={guardrailsList || []}
tagsList={tagsList || {}}
accessToken={accessToken || ""}
/>
</>
)}
<div className="flex justify-between items-center mb-4">
<Tooltip title="Get help on our github">
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
</Tooltip>
<div className="space-x-2">
<Button data-testid="test-connect-btn" onClick={handleTestConnection} loading={isTestingConnection}>
Test Connect
</Button>
<Button data-testid="add-model-btn" htmlType="submit">
Add Model
</Button>
</div>
</div>
</>
)}
<div className="flex justify-between items-center mb-4">
<Tooltip title="Get help on our github">
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
</Tooltip>
<div className="space-x-2">
<Button data-testid="test-connect-btn" onClick={handleTestConnection} loading={isTestingConnection}>
Test Connect
</Button>
<Button data-testid="add-model-btn" htmlType="submit">
Add Model
</Button>
</div>
</div>
</>
</Form>
</form>
</MountedFormProvider>
</FormProvider>
</Card>
{/* Test Connection Results Modal */}
@ -408,10 +458,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
<ConnectionErrorDisplay
// The key prop tells React to create a fresh component instance when it changes
key={connectionTestId}
formValues={form.getFieldsValue()}
formValues={mountedValues()}
accessToken={accessToken}
testMode={testMode}
modelName={form.getFieldValue("model_name") || form.getFieldValue("model")}
modelName={connectionTestModelName(form.getValues())}
onClose={() => {
setIsResultModalVisible(false);
setIsTestingConnection(false);

View file

@ -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(
<AdvancedSettings
showAdvancedSettings={true}
setShowAdvancedSettings={() => {}}
guardrailsList={[]}
tagsList={{}}
accessToken="test-token"
/>,
<MountedFormHost>
<AdvancedSettings
showAdvancedSettings={true}
setShowAdvancedSettings={() => {}}
guardrailsList={[]}
tagsList={{}}
accessToken="test-token"
/>
</MountedFormHost>,
);
describe("AdvancedSettings", () => {

View file

@ -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<AdvancedSettingsProps> = ({
showAdvancedSettings,
setShowAdvancedSettings,
@ -47,95 +75,36 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
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 (
<>
<Collapsible className="mt-2 mb-4 overflow-hidden rounded-lg border">
<CollapsibleTrigger className="group/section flex w-full items-center justify-between px-4 py-3 text-left">
<b>Advanced Settings</b>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-3">
<div className="bg-white rounded-lg">
<Form.Item label="Custom Pricing" name="custom_pricing" valuePropName="checked" className="mb-4">
<Switch onChange={handleCustomPricingChange} className="bg-gray-600" />
</Form.Item>
<div className="rounded-lg">
<MountedFormField name="custom_pricing" label="Custom Pricing" className="mb-4">
{(control) => (
<Switch
id={control.id}
checked={control.value === true}
onChange={(checked) => {
control.onChange(checked);
setCustomPricing(checked);
}}
className="bg-gray-600"
/>
)}
</MountedFormField>
<Form.Item
<MountedFormField
name="vector_store_ids"
label={
<span>
Attached Knowledge Bases (RAG){" "}
@ -151,18 +120,21 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
</Tooltip>
</span>
}
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."
>
<VectorStoreSelector
onChange={() => {}}
accessToken={accessToken}
placeholder="Select knowledge bases (optional)"
/>
</Form.Item>
{(control) => (
<VectorStoreSelector
onChange={control.onChange}
value={control.value as string[] | undefined}
accessToken={accessToken}
placeholder="Select knowledge bases (optional)"
/>
)}
</MountedFormField>
<Form.Item
<MountedFormField
name="guardrails"
label={
<span>
Guardrails{" "}
@ -178,199 +150,331 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
</Tooltip>
</span>
}
name="guardrails"
className="mt-4"
help="Select existing guardrails. Go to 'Guardrails' tab to create new guardrails."
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter guardrails"
options={guardrailsList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
{(control) => (
<Select
id={control.id}
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter guardrails"
value={control.value as string[] | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
options={guardrailsList.map((name) => ({ value: name, label: name }))}
/>
)}
</MountedFormField>
<Form.Item label="Tags" name="tags" className="mb-4">
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter tags"
options={Object.values(tagsList).map((tag) => ({
value: tag.name,
label: tag.name,
title: tag.description || tag.name,
}))}
/>
</Form.Item>
<MountedFormField name="tags" label="Tags" className="mb-4">
{(control) => (
<Select
id={control.id}
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter tags"
value={control.value as string[] | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
options={Object.values(tagsList).map((tag) => ({
value: tag.name,
label: tag.name,
title: tag.description || tag.name,
}))}
/>
)}
</MountedFormField>
{ptuCostAttributionEnabled && (
<>
<Form.Item
label="PTU Count"
<MountedFormField
name={PTU_COUNT_FIELD}
dependencies={[PTU_RATE_FIELD]}
rules={[{ validator: validateNumber }, ...ptuCountRules, ptuPairRule(PTU_RATE_FIELD)]}
tooltip="Provisioned throughput units for this deployment. Set together with Cost per PTU / Hour and a Team to attribute a flat daily cost."
label={labelWithHint(
"PTU Count",
"Provisioned throughput units for this deployment. Set together with Cost per PTU / Hour and a Team to attribute a flat daily cost.",
)}
rules={{
deps: REVALIDATED_WHEN_PTU_COUNT_CHANGES,
validate: antdRules({ validator: validateNumber }, ...ptuCountRules, ptuPairRule(PTU_RATE_FIELD)),
}}
className="mb-4"
>
<Input placeholder="e.g. 15" />
</Form.Item>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="e.g. 15"
/>
)}
</MountedFormField>
<Form.Item
label="Calculated Cost per PTU / Hour (USD)"
<MountedFormField
name={PTU_RATE_FIELD}
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ...ptuRateRules, ptuPairRule(PTU_COUNT_FIELD)]}
tooltip="Flat cost = PTU count * this rate * active hours, attributed to the deployment's team."
label={labelWithHint(
"Calculated Cost per PTU / Hour (USD)",
"Flat cost = PTU count * this rate * active hours, attributed to the deployment's team.",
)}
rules={{
deps: [PTU_COUNT_FIELD],
validate: antdRules({ validator: validateNumber }, ...ptuRateRules, ptuPairRule(PTU_COUNT_FIELD)),
}}
className="mb-4"
>
<Input placeholder="e.g. 2.00" />
</Form.Item>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="e.g. 2.00"
/>
)}
</MountedFormField>
<Form.Item
label="PTU Effective From (UTC)"
<MountedFormField
name={PTU_START_FIELD}
dependencies={[PTU_COUNT_FIELD, PTU_END_FIELD]}
rules={[ptuStartRequiredRule(PTU_COUNT_FIELD), ptuWindowOrderRule(PTU_END_FIELD, "start")]}
tooltip="Start of the PTU window, required when PTU Count is set. Flat cost accrues by the hour within the window; a window opening at 23:00 charges one hour that day."
label={labelWithHint(
"PTU Effective From (UTC)",
"Start of the PTU window, required when PTU Count is set. Flat cost accrues by the hour within the window; a window opening at 23:00 charges one hour that day.",
)}
rules={{
deps: [PTU_END_FIELD],
validate: antdRules(
ptuStartRequiredRule(PTU_COUNT_FIELD),
ptuWindowOrderRule(PTU_END_FIELD, "start"),
),
}}
className="mb-4"
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
{(control) => (
<DatePicker
id={control.id}
showTime
style={{ width: "100%" }}
value={control.value as never}
onChange={control.onChange}
onBlur={control.onBlur}
/>
)}
</MountedFormField>
<Form.Item
label="PTU Effective To (UTC)"
<MountedFormField
name={PTU_END_FIELD}
dependencies={[PTU_START_FIELD]}
rules={[ptuWindowOrderRule(PTU_START_FIELD, "end")]}
tooltip="Optional end of the PTU window (exclusive). Leave blank for open-ended."
label={labelWithHint(
"PTU Effective To (UTC)",
"Optional end of the PTU window (exclusive). Leave blank for open-ended.",
)}
rules={{
deps: [PTU_START_FIELD],
validate: antdRules(ptuWindowOrderRule(PTU_START_FIELD, "end")),
}}
className="mb-4"
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
{(control) => (
<DatePicker
id={control.id}
showTime
style={{ width: "100%" }}
value={control.value as never}
onChange={control.onChange}
onBlur={control.onBlur}
/>
)}
</MountedFormField>
</>
)}
{customPricing && (
<div className="ml-6 pl-4 border-l-2 border-gray-200">
<Form.Item label="Pricing Model" name="pricing_model" className="mb-4">
<Select
defaultValue="per_token"
onChange={(value: "per_token" | "per_second") => setPricingModel(value)}
options={[
{ value: "per_token", label: "Per Million Tokens" },
{ value: "per_second", label: "Per Second" },
]}
/>
</Form.Item>
<div className="ml-6 pl-4 border-l-2 border-border">
<MountedFormField name="pricing_model" label="Pricing Model" className="mb-4">
{(control) => (
<Select
id={control.id}
defaultValue="per_token"
value={control.value as "per_token" | "per_second" | undefined}
onBlur={control.onBlur}
onChange={(value: "per_token" | "per_second") => {
control.onChange(value);
setPricingModel(value);
}}
options={[
{ value: "per_token", label: "Per Million Tokens" },
{ value: "per_second", label: "Per Second" },
]}
/>
)}
</MountedFormField>
{pricingModel === "per_token" ? (
<>
<Form.Item
label="Input Cost (per 1M tokens)"
<MountedFormField
name="input_cost_per_token"
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)]}
label="Input Cost (per 1M tokens)"
rules={usageCostRules}
className="mb-4"
>
<Input />
</Form.Item>
<Form.Item
label="Output Cost (per 1M tokens)"
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
/>
)}
</MountedFormField>
<MountedFormField
name="output_cost_per_token"
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)]}
label="Output Cost (per 1M tokens)"
rules={usageCostRules}
className="mb-4"
>
<Input />
</Form.Item>
<Form.Item
label="Cache Read Cost (per 1M tokens)"
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
/>
)}
</MountedFormField>
<MountedFormField
name="cache_read_input_token_cost"
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)]}
tooltip="If left blank, defaults to Input Cost."
label={labelWithHint("Cache Read Cost (per 1M tokens)", "If left blank, defaults to Input Cost.")}
rules={usageCostRules}
className="mb-4"
>
<Input placeholder="Defaults to Input Cost if blank" />
</Form.Item>
<Form.Item
label="Cache Write Cost (per 1M tokens)"
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="Defaults to Input Cost if blank"
/>
)}
</MountedFormField>
<MountedFormField
name="cache_creation_input_token_cost"
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)]}
tooltip="If left blank, defaults to Input Cost (the backend falls back to input_cost_per_token when no cache-write rate is set)."
label={labelWithHint(
"Cache Write Cost (per 1M tokens)",
"If left blank, defaults to Input Cost (the backend falls back to input_cost_per_token when no cache-write rate is set).",
)}
rules={usageCostRules}
className="mb-4"
>
<Input placeholder="Defaults to Input Cost if blank" />
</Form.Item>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="Defaults to Input Cost if blank"
/>
)}
</MountedFormField>
</>
) : (
<Form.Item
label="Cost Per Second"
<MountedFormField
name="input_cost_per_second"
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ptuNoUsageCostRule(PTU_COUNT_FIELD)]}
label="Cost Per Second"
rules={usageCostRules}
className="mb-4"
>
<Input />
</Form.Item>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
/>
)}
</MountedFormField>
)}
</div>
)}
<Form.Item
label="Use in pass through routes"
<MountedFormField
name="use_in_pass_through"
valuePropName="checked"
className="mb-4 mt-4"
tooltip={
label={labelWithHint(
"Use in pass through routes",
<span>
Allow using these credentials in pass through routes.{" "}
<Link href="https://docs.litellm.ai/docs/pass_through/vertex_ai" target="_blank">
Learn more
</Link>
</span>
}
</span>,
)}
className="mb-4 mt-4"
>
<Switch onChange={handlePassThroughChange} className="bg-gray-600" />
</Form.Item>
{(control) => (
<Switch
id={control.id}
checked={control.value === true}
onChange={control.onChange}
className="bg-gray-600"
/>
)}
</MountedFormField>
<Form.Item
label={CACHE_CONTROL_LABEL}
<MountedFormField
name="cache_control"
valuePropName="checked"
label={labelWithHint(CACHE_CONTROL_LABEL, CACHE_CONTROL_TOOLTIP)}
className="mb-4"
tooltip={CACHE_CONTROL_TOOLTIP}
>
<Switch onChange={handleCacheControlChange} className="bg-gray-600" />
</Form.Item>
{(control) => (
<Switch
id={control.id}
checked={control.value === true}
onChange={(checked) => {
control.onChange(checked);
setShowCacheControl(checked);
}}
className="bg-gray-600"
/>
)}
</MountedFormField>
{showCacheControl && (
<Form.Item name="cache_control_injection_points" initialValue={[NEW_CACHE_CONTROL_POINT]} noStyle>
<CacheControlInjectionPoints />
</Form.Item>
<MountedFormField name="cache_control_injection_points" defaultValue={[NEW_CACHE_CONTROL_POINT]} bare>
{(control) => (
<CacheControlInjectionPoints
value={control.value as React.ComponentProps<typeof CacheControlInjectionPoints>["value"]}
onChange={control.onChange}
/>
)}
</MountedFormField>
)}
<Form.Item
label="LiteLLM Params"
<MountedFormField
name="litellm_extra_params"
tooltip="Optional litellm params used for making a litellm.completion() call."
label={labelWithHint(
"LiteLLM Params",
"Optional litellm params used for making a litellm.completion() call.",
)}
className="mb-4 mt-4"
rules={[{ validator: formItemValidateJSON }]}
rules={{ validate: antdRules({ validator: formItemValidateJSON }) }}
>
<TextArea
rows={4}
placeholder='{
{(control) => (
<TextArea
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
rows={4}
placeholder='{
"rpm": 100,
"timeout": 0,
"stream_timeout": 0
}'
/>
</Form.Item>
/>
)}
</MountedFormField>
<Row className="mb-4">
<Col span={10}></Col>
<Col span={10}>
<p className="text-gray-600 text-sm">
<p className="text-muted-foreground text-sm">
Pass JSON of litellm supported params{" "}
<Link href="https://docs.litellm.ai/docs/completion/input" target="_blank">
litellm.completion() call
@ -378,20 +482,28 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
</p>
</Col>
</Row>
<Form.Item
label="Model Info"
<MountedFormField
name="model_info_params"
tooltip="Optional model info params. Returned when calling `/model/info` endpoint."
label={labelWithHint(
"Model Info",
"Optional model info params. Returned when calling `/model/info` endpoint.",
)}
className="mb-0"
rules={[{ validator: formItemValidateJSON }]}
rules={{ validate: antdRules({ validator: formItemValidateJSON }) }}
>
<TextArea
rows={4}
placeholder='{
{(control) => (
<TextArea
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
rows={4}
placeholder='{
"mode": "chat"
}'
/>
</Form.Item>
/>
)}
</MountedFormField>
</div>
</CollapsibleContent>
</Collapsible>

View file

@ -1,13 +1,13 @@
import { render, screen } from "@testing-library/react";
import { Form } from "antd";
import { describe, expect, it } from "vitest";
import { MountedFormHost } from "../../../tests/mounted-form-host";
import ConditionalPublicModelName from "./conditional_public_model_name";
describe("ConditionalPublicModelName", () => {
it("should render", () => {
render(
<Form
initialValues={{
<MountedFormHost
defaultValues={{
model: ["gpt-4"],
model_mappings: [
{
@ -18,7 +18,7 @@ describe("ConditionalPublicModelName", () => {
}}
>
<ConditionalPublicModelName />
</Form>,
</MountedFormHost>,
);
expect(screen.getByText("Model Mappings")).toBeInTheDocument();

View file

@ -1,24 +1,46 @@
import React, { useEffect, useState } from "react";
import { Form, Table } from "antd";
import { Table } from "antd";
import { useFormContext, useWatch } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { antdRules } from "../common_components/antdFormRules";
import { MountedFormField, type MountedFormValues } from "../common_components/MountedFormField";
import { Providers } from "../provider_info_helpers";
interface ModelMapping {
public_name: string;
litellm_model: string;
}
const modelMappingsRule = {
validator: async (_: unknown, value: unknown) => {
if (!value || (value as ModelMapping[]).length === 0) {
throw new Error("At least one model mapping is required");
}
const invalidMappings = (value as ModelMapping[]).filter(
(mapping) => !mapping.public_name || mapping.public_name.trim() === "",
);
if (invalidMappings.length > 0) {
throw new Error("All model mappings must have valid public names");
}
},
};
const ConditionalPublicModelName: React.FC = () => {
const form = Form.useFormInstance();
const form = useFormContext<MountedFormValues>();
const [tableKey, setTableKey] = useState(0); // Add a key to force table re-render
// Watch the 'model' field for changes and ensure it's always an array
const modelValue = Form.useWatch("model", form) || [];
const modelValue = useWatch({ control: form.control, name: "model" }) || [];
const selectedModels = Array.isArray(modelValue) ? modelValue : [modelValue];
const customModelName = Form.useWatch("custom_model_name", form);
const customModelName = useWatch({ control: form.control, name: "custom_model_name" }) as string | undefined;
const showPublicModelName = !selectedModels.includes("all-wildcard");
const selectedProvider = Form.useWatch("custom_llm_provider", form);
const selectedProvider = useWatch({ control: form.control, name: "custom_llm_provider" });
// Force table to re-render when custom model name changes
useEffect(() => {
if (customModelName && selectedModels.includes("custom")) {
const currentMappings = form.getFieldValue("model_mappings") || [];
const updatedMappings = currentMappings.map((mapping: any) => {
const currentMappings = (form.getValues("model_mappings") as ModelMapping[]) || [];
const updatedMappings = currentMappings.map((mapping) => {
if (mapping.public_name === "custom" || mapping.litellm_model === "custom") {
if (selectedProvider === Providers.Azure) {
return {
@ -33,7 +55,7 @@ const ConditionalPublicModelName: React.FC = () => {
}
return mapping;
});
form.setFieldValue("model_mappings", updatedMappings);
form.setValue("model_mappings", updatedMappings);
setTableKey((prev) => prev + 1); // Force table re-render
}
}, [customModelName, selectedModels, selectedProvider, form]);
@ -42,13 +64,13 @@ const ConditionalPublicModelName: React.FC = () => {
useEffect(() => {
if (selectedModels.length > 0 && !selectedModels.includes("all-wildcard")) {
// Check if we already have mappings that match the selected models
const currentMappings = form.getFieldValue("model_mappings") || [];
const currentMappings = (form.getValues("model_mappings") as ModelMapping[]) || [];
// Only update if the mappings don't exist or don't match the selected models
const shouldUpdateMappings =
currentMappings.length !== selectedModels.length ||
!selectedModels.every((model) =>
currentMappings.some((mapping: { public_name: string; litellm_model: string }) => {
currentMappings.some((mapping) => {
if (model === "custom") {
return mapping.litellm_model === "custom" || mapping.litellm_model === customModelName;
}
@ -85,7 +107,7 @@ const ConditionalPublicModelName: React.FC = () => {
};
});
form.setFieldValue("model_mappings", mappings);
form.setValue("model_mappings", mappings);
setTableKey((prev) => prev + 1); // Force table re-render
}
}
@ -98,16 +120,16 @@ const ConditionalPublicModelName: React.FC = () => {
<div className="mb-2 font-normal">The name you specify in your API calls to LiteLLM Proxy</div>
<div className="mb-2 font-normal">
<strong>Example:</strong> If you name your public model{" "}
<code className="bg-gray-700 px-1 py-0.5 rounded-sm text-xs">example-name</code>, and choose{" "}
<code className="bg-gray-700 px-1 py-0.5 rounded-sm text-xs">openai/qwen-plus-latest</code> as the LiteLLM model
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">example-name</code>, and choose{" "}
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">openai/qwen-plus-latest</code> as the LiteLLM model
</div>
<div className="mb-2 font-normal">
<strong>Usage:</strong> You make an API call to the LiteLLM proxy with{" "}
<code className="bg-gray-700 px-1 py-0.5 rounded-sm text-xs">model = &quot;example-name&quot;</code>
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">model = &quot;example-name&quot;</code>
</div>
<div className="font-normal">
<strong>Result:</strong> LiteLLM sends{" "}
<code className="bg-gray-700 px-1 py-0.5 rounded-sm text-xs">qwen-plus-latest</code> to the provider
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">qwen-plus-latest</code> to the provider
</div>
</>
);
@ -130,12 +152,12 @@ const ConditionalPublicModelName: React.FC = () => {
value={text}
onChange={(e) => {
const newValue = e.target.value;
const newMappings = [...form.getFieldValue("model_mappings")];
const newMappings = [...((form.getValues("model_mappings") as ModelMapping[]) ?? [])];
// Check conditions for Anthropic -1m suffix handling
const isAnthropic = selectedProvider === Providers.Anthropic;
const endsWith1m = newValue.endsWith("-1m");
const litellmParams = form.getFieldValue("litellm_extra_params");
const litellmParams = form.getValues("litellm_extra_params") as string | undefined;
const isLitellmParamsEmpty = !litellmParams || litellmParams.trim() === "";
let finalPublicName = newValue;
@ -147,14 +169,14 @@ const ConditionalPublicModelName: React.FC = () => {
null,
2,
);
form.setFieldValue("litellm_extra_params", litellmParamsValue);
form.setValue("litellm_extra_params", litellmParamsValue);
// Remove -1m suffix from public_name
finalPublicName = newValue.slice(0, -3); // Remove "-1m" (3 characters)
}
newMappings[index].public_name = finalPublicName;
form.setFieldValue("model_mappings", newMappings);
form.setValue("model_mappings", newMappings);
}}
/>
);
@ -173,41 +195,28 @@ const ConditionalPublicModelName: React.FC = () => {
];
return (
<>
<Form.Item
label="Model Mappings"
name="model_mappings"
tooltip="Map public model names to LiteLLM model names for load balancing"
labelCol={{ span: 10 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
rules={[
{
required: true,
validator: async (_, value) => {
if (!value || value.length === 0) {
throw new Error("At least one model mapping is required");
}
// Check if all mappings have valid public names
const invalidMappings = value.filter(
(mapping: any) => !mapping.public_name || mapping.public_name.trim() === "",
);
if (invalidMappings.length > 0) {
throw new Error("All model mappings must have valid public names");
}
},
},
]}
>
<MountedFormField
name="model_mappings"
label={
<span className="flex items-center">
Model Mappings
<SimpleTooltip content="Map public model names to LiteLLM model names for load balancing" />
</span>
}
required
rules={{ validate: antdRules(modelMappingsRule) }}
className="mb-4"
>
{(control) => (
<Table
key={tableKey} // Add key to force re-render
dataSource={form.getFieldValue("model_mappings")}
dataSource={control.value as ModelMapping[] | undefined}
columns={columns}
pagination={false}
size="small"
/>
</Form.Item>
</>
)}
</MountedFormField>
);
};

View file

@ -1,28 +1,28 @@
import { render } from "@testing-library/react";
import { Form } from "antd";
import { describe, expect, it } from "vitest";
import { getPlaceholder, Providers } from "../provider_info_helpers";
import { MountedFormHost } from "../../../tests/mounted-form-host";
import LiteLLMModelNameField from "./litellm_model_name";
describe("LitellmModelNameField", () => {
it("should render", () => {
const { getByText } = render(
<Form>
<MountedFormHost>
<LiteLLMModelNameField
selectedProvider={Providers.OpenAI}
providerModels={[]}
getPlaceholder={getPlaceholder}
/>
</Form>,
</MountedFormHost>,
);
expect(getByText("LiteLLM Model Name(s)")).toBeInTheDocument();
});
it("should show Azure placeholder as 'my-deployment'", () => {
const { getByPlaceholderText, queryByPlaceholderText } = render(
<Form>
<MountedFormHost>
<LiteLLMModelNameField selectedProvider={Providers.Azure} providerModels={[]} getPlaceholder={getPlaceholder} />
</Form>,
</MountedFormHost>,
);
expect(getByPlaceholderText("my-deployment")).toBeInTheDocument();
expect(queryByPlaceholderText("gpt-3.5-turbo")).not.toBeInTheDocument();

View file

@ -1,7 +1,11 @@
import React from "react";
import { Form, Select as AntSelect } from "antd";
import { Select as AntSelect } from "antd";
import { useFormContext, useWatch } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Row, Col } from "antd";
import { antdRequired } from "../common_components/antdFormRules";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { MountedFormField, type MountedFormValues } from "../common_components/MountedFormField";
import { Providers } from "../provider_info_helpers";
interface LiteLLMModelNameFieldProps {
@ -15,7 +19,9 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
providerModels,
getPlaceholder,
}) => {
const form = Form.useFormInstance();
const form = useFormContext<MountedFormValues>();
const modelValue = useWatch({ control: form.control, name: "model" });
const selectedModels = Array.isArray(modelValue) ? modelValue : [modelValue];
const handleModelChange = (value: string | string[]) => {
// Ensure value is always treated as an array
@ -23,10 +29,11 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
// If "all-wildcard" is selected, clear the model_name field
if (values.includes("all-wildcard")) {
form.setFieldsValue({ model_name: undefined, model_mappings: [] });
form.setValue("model_name", undefined);
form.setValue("model_mappings", []);
} else {
// Get current model value to check if we need to update
const currentModel = form.getFieldValue("model");
const currentModel = form.getValues("model");
// Only update if the value has actually changed
if (JSON.stringify(currentModel) !== JSON.stringify(values)) {
@ -45,10 +52,8 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
});
// Update both fields in one call to reduce re-renders
form.setFieldsValue({
model: values,
model_mappings: mappings,
});
form.setValue("model", values);
form.setValue("model_mappings", mappings);
}
}
};
@ -67,10 +72,8 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
: [];
// Update both fields
form.setFieldsValue({
model: deploymentName,
model_mappings: mappings,
});
form.setValue("model", deploymentName);
form.setValue("model_mappings", mappings);
};
// Handle custom model name changes
@ -78,7 +81,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
const customName = e.target.value;
// Immediately update the model mappings
const currentMappings = form.getFieldValue("model_mappings") || [];
const currentMappings = (form.getValues("model_mappings") as any[]) || [];
const updatedMappings = currentMappings.map((mapping: any) => {
if (mapping.public_name === "custom" || mapping.litellm_model === "custom") {
if (selectedProvider === Providers.Azure) {
@ -95,43 +98,54 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
return mapping;
});
form.setFieldsValue({ model_mappings: updatedMappings });
form.setValue("model_mappings", updatedMappings);
};
return (
<>
<Form.Item
label="LiteLLM Model Name(s)"
tooltip="The model name LiteLLM will send to the LLM API"
<MountedFormField
name="model"
label={labelWithHint("LiteLLM Model Name(s)", "The model name LiteLLM will send to the LLM API")}
required
rules={{
validate: {
required: antdRequired(
`Please enter ${selectedProvider === Providers.Azure ? "a deployment name" : "at least one model"}.`,
),
},
}}
className="mb-0"
>
<Form.Item
name="model"
rules={[
{
required: true,
message: `Please enter ${selectedProvider === Providers.Azure ? "a deployment name" : "at least one model"}.`,
},
]}
noStyle
>
{selectedProvider === Providers.Azure ||
{(control) =>
selectedProvider === Providers.Azure ||
selectedProvider === Providers.OpenAI_Compatible ||
selectedProvider === Providers.Ollama ? (
<>
<Input
placeholder={getPlaceholder(selectedProvider)}
onChange={selectedProvider === Providers.Azure ? handleAzureDeploymentNameChange : undefined}
/>
</>
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onBlur={control.onBlur}
placeholder={getPlaceholder(selectedProvider)}
onChange={(event) => {
control.onChange(event);
if (selectedProvider === Providers.Azure) {
handleAzureDeploymentNameChange(event);
}
}}
/>
) : providerModels.length > 0 ? (
<AntSelect
id={control.id}
data-testid="model-name-select"
mode="multiple"
allowClear
showSearch
placeholder="Select models"
onChange={handleModelChange}
value={control.value as string[] | undefined}
onBlur={control.onBlur}
onChange={(value) => {
control.onChange(value);
handleModelChange(value);
}}
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
@ -151,34 +165,40 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
style={{ width: "100%" }}
/>
) : (
<Input placeholder={getPlaceholder(selectedProvider)} />
)}
</Form.Item>
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={getPlaceholder(selectedProvider)}
/>
)
}
</MountedFormField>
{/* Custom Model Name field */}
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.model !== currentValues.model}>
{({ getFieldValue }) => {
const selectedModels = getFieldValue("model") || [];
const modelArray = Array.isArray(selectedModels) ? selectedModels : [selectedModels];
return (
modelArray.includes("custom") && (
<Form.Item
name="custom_model_name"
rules={[{ required: true, message: "Please enter a custom model name." }]}
className="mt-2"
>
<Input
placeholder={
selectedProvider === Providers.Azure ? "Enter Azure deployment name" : "Enter custom model name"
}
onChange={handleCustomModelNameChange}
/>
</Form.Item>
)
);
}}
</Form.Item>
</Form.Item>
{selectedModels.includes("custom") && (
<MountedFormField
name="custom_model_name"
required
rules={{ validate: { required: antdRequired("Please enter a custom model name.") } }}
className="mt-2"
>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onBlur={control.onBlur}
placeholder={
selectedProvider === Providers.Azure ? "Enter Azure deployment name" : "Enter custom model name"
}
onChange={(event) => {
control.onChange(event);
handleCustomModelNameChange(event);
}}
/>
)}
</MountedFormField>
)}
<Row>
<Col span={10}></Col>
<Col span={14}>

View file

@ -1,8 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Form } from "antd";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Providers } from "../provider_info_helpers";
import { MountedFormHost } from "../../../tests/mounted-form-host";
import ProviderSpecificFields from "./provider_specific_fields";
vi.mock("../networking", async () => {
@ -129,9 +129,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.OpenAI} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -144,9 +144,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.OpenAI} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -165,9 +165,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.OpenAI} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -182,9 +182,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={"Hosted_Vllm" as Providers} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -200,9 +200,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.Azure} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -231,9 +231,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.Azure} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -256,9 +256,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.Azure} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -281,9 +281,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.Azure} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);
@ -316,9 +316,9 @@ describe("ProviderSpecificFields", () => {
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Form>
<MountedFormHost>
<ProviderSpecificFields selectedProvider={Providers.Azure} />
</Form>
</MountedFormHost>
</QueryClientProvider>,
);

View file

@ -1,10 +1,18 @@
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
import { UploadOutlined } from "@ant-design/icons";
import { Input } from "@/components/ui/input";
import { Button as Button2, Col, Form, Input as AntdInput, Row, Select, Typography, Upload, UploadProps } from "antd";
import { Button as Button2, Col, Input as AntdInput, Row, Select, Typography, Upload, UploadProps } from "antd";
import React from "react";
import { useFormContext } from "react-hook-form";
import { antdRequired } from "../common_components/antdFormRules";
import {
MountedFormField,
type MountedFieldControlProps,
type MountedFormValues,
} from "../common_components/MountedFormField";
import { CredentialItem, ProviderCredentialFieldMetadata } from "../networking";
import { provider_map, Providers } from "../provider_info_helpers";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
const { Link } = Typography;
interface ProviderSpecificFieldsProps {
@ -100,7 +108,7 @@ export const createCredentialFromModel = (provider: string, modelData: any): Cre
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selectedProvider, uploadProps }) => {
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
const form = Form.useFormInstance(); // Get form instance from context
const form = useFormContext<MountedFormValues>();
const { data: providerMetadata, isLoading, error: loadError } = useProviderFields();
@ -185,12 +193,12 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
const apiVersion = getApiVersionFromApiBase(event.target.value);
if (apiVersion) {
lastInferredApiVersionRef.current = apiVersion;
form.setFieldsValue({ api_version: apiVersion });
form.setValue("api_version", apiVersion);
return;
}
if (form.getFieldValue("api_version") === lastInferredApiVersionRef.current) {
form.setFieldsValue({ api_version: "" });
if (form.getValues("api_version") === lastInferredApiVersionRef.current) {
form.setValue("api_version", "");
}
lastInferredApiVersionRef.current = null;
},
@ -206,7 +214,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
reader.onload = (e) => {
if (e.target) {
const jsonStr = e.target.result as string;
form.setFieldsValue({ vertex_credentials: jsonStr });
form.setValue("vertex_credentials", jsonStr);
}
};
reader.readAsText(file);
@ -216,10 +224,17 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
},
};
const renderFieldControl = (field: ProviderCredentialField) => {
const renderFieldControl = (field: ProviderCredentialField, control: MountedFieldControlProps) => {
if (field.type === "select") {
return (
<Select placeholder={field.placeholder} defaultValue={field.defaultValue}>
<Select
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={field.placeholder}
defaultValue={field.defaultValue}
>
{field.options?.map((option) => (
<Select.Option key={option} value={option}>
{option}
@ -234,6 +249,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
<Upload
{...handleUpload}
onChange={(info) => {
control.onChange(info);
if (uploadProps?.onChange) {
uploadProps.onChange(info);
}
@ -247,6 +263,10 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
if (field.type === "textarea") {
return (
<AntdInput.TextArea
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={field.placeholder}
defaultValue={field.defaultValue}
rows={6}
@ -256,15 +276,32 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
}
if (field.type === "password") {
return <AntdInput.Password placeholder={field.placeholder} defaultValue={field.defaultValue} />;
return (
<AntdInput.Password
id={control.id}
value={control.value as string | undefined}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder={field.placeholder}
defaultValue={field.defaultValue}
/>
);
}
return (
<Input
id={control.id}
value={(control.value as string | undefined) ?? undefined}
onBlur={control.onBlur}
placeholder={field.placeholder}
type="text"
defaultValue={field.defaultValue}
onChange={field.key === "api_base" ? handleApiBaseChange : undefined}
onChange={(event) => {
control.onChange(event);
if (field.key === "api_base") {
handleApiBaseChange(event);
}
}}
/>
);
};
@ -281,7 +318,7 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
{loadError && allFields.length === 0 && (
<Row>
<Col span={24}>
<p className="text-sm mb-2 text-red-500">
<p className="text-sm mb-2 text-destructive">
{loadError instanceof Error ? loadError.message : "Failed to load provider credential fields"}
</p>
</Col>
@ -289,15 +326,15 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
)}
{allFields.map((field) => (
<React.Fragment key={field.key}>
<Form.Item
label={field.label}
<MountedFormField
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
name={field.key}
rules={field.required ? [{ required: true, message: "Required" }] : undefined}
tooltip={field.tooltip}
className={field.key === "vertex_credentials" ? "mb-0" : undefined}
required={field.required}
rules={field.required ? { validate: { required: antdRequired("Required") } } : undefined}
className={field.key === "vertex_credentials" ? "mb-0" : "mb-4"}
>
{renderFieldControl(field)}
</Form.Item>
{(control) => renderFieldControl(field, control)}
</MountedFormField>
{/* Special case for Vertex Credentials help text */}
{field.key === "vertex_credentials" && (

View file

@ -0,0 +1,44 @@
import type { Validate } from "react-hook-form";
import type { MountedFormValues } from "./MountedFormField";
interface AntdRuleForm {
getFieldValue: (name: string) => unknown;
isFieldTouched?: (name: string) => boolean;
}
interface AntdRule {
validator: (rule: never, value: never) => Promise<void>;
}
type AntdRuleSource = AntdRule | ((form: AntdRuleForm) => AntdRule);
type MountedValidate = Validate<unknown, MountedFormValues>;
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
const isEmptyList = (value: unknown): boolean => Array.isArray(value) && value.length === 0;
export const antdRequired =
(message: string): MountedValidate =>
(value) =>
isBlank(value) || isEmptyList(value) ? message : true;
const toMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export const antdRules = (...rules: readonly AntdRuleSource[]): Record<string, MountedValidate> =>
Object.fromEntries(
rules.map((rule, index) => [
`antd_${index}`,
async (value: unknown, values: MountedFormValues) => {
const resolved = typeof rule === "function" ? rule({ getFieldValue: (name) => values[name] }) : rule;
const validator = resolved.validator as (rule: unknown, value: unknown) => Promise<void>;
try {
await validator(null, value);
return true;
} catch (error) {
return toMessage(error);
}
},
]),
);

View file

@ -1,8 +1,18 @@
import { Input } from "@/components/ui/input";
import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd";
import { Select as AntdSelect, Button, Modal, Tooltip, Typography } from "antd";
import type { UploadProps } from "antd/es/upload";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import ProviderSpecificFields from "../add_model/provider_specific_fields";
import { antdRequired } from "../common_components/antdFormRules";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import {
MountedFormField,
MountedFormProvider,
projectMountedValues,
useMountRegistry,
type MountedFormValues,
} from "../common_components/MountedFormField";
import { CredentialItem } from "../networking";
import { Providers } from "../provider_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
@ -28,7 +38,6 @@ export default function CredentialModal({
existingCredential = null,
}: CredentialModalProps) {
const isEdit = mode === "edit";
const [form] = Form.useForm();
const [selectedProvider, setSelectedProvider] = useState<Providers>(
(existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI,
);
@ -43,7 +52,21 @@ export default function CredentialModal({
}
: undefined;
const handleSubmit = (values: any) => {
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: initialValues });
const registry = useMountRegistry();
const formAdapter = {
getFieldValue: (field: string) => form.getValues(field),
resetFields: () => form.reset(),
setFieldValue: (field: string, value: unknown) => form.setValue(field, value),
};
const handleSubmit = async () => {
const isValid = await form.trigger(registry.mountedNames() as string[]);
if (!isValid) {
return;
}
const values = projectMountedValues(registry, form.getValues);
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
if (value !== "" && value !== undefined && value !== null) {
acc[key] = value;
@ -51,12 +74,12 @@ export default function CredentialModal({
return acc;
}, {} as any);
onSubmit(filteredValues);
form.resetFields();
form.reset();
};
const closeAndReset = () => {
onCancel();
form.resetFields();
form.reset();
};
return (
@ -68,53 +91,80 @@ export default function CredentialModal({
width={600}
destroyOnHidden={isEdit}
>
<Form form={form} onFinish={handleSubmit} layout="vertical" initialValues={initialValues}>
<Form.Item
label="Credential Name:"
name="credential_name"
rules={[{ required: true, message: "Credential name is required" }]}
>
<Input placeholder="Enter a friendly name for these credentials" disabled={isEdit} />
</Form.Item>
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="Provider:"
name="custom_llm_provider"
tooltip="Helper to auto-populate provider specific fields"
>
<AntdSelect
showSearch
onChange={(value) => {
resetCredentialFormOnProviderChange(form, value as Providers, setSelectedProvider);
<FormProvider {...form}>
<MountedFormProvider value={{ control: form.control, registry }}>
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => (
<AntdSelect.Option key={providerEnum} value={providerEnum}>
<div className="flex items-center space-x-2">
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
<span>{providerDisplayName}</span>
</div>
</AntdSelect.Option>
))}
</AntdSelect>
</Form.Item>
<MountedFormField
label="Credential Name:"
name="credential_name"
required
rules={{ validate: { required: antdRequired("Credential name is required") } }}
className="mb-4"
>
{(control) => (
<Input
id={control.id}
value={(control.value as string | undefined) ?? ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="Enter a friendly name for these credentials"
disabled={isEdit}
/>
)}
</MountedFormField>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
<MountedFormField
label={labelWithHint("Provider:", "Helper to auto-populate provider specific fields")}
name="custom_llm_provider"
required
rules={{ validate: { required: antdRequired("Required") } }}
className="mb-4"
>
{(control) => (
<AntdSelect
id={control.id}
showSearch
value={control.value as string | undefined}
onBlur={control.onBlur}
onChange={(value) => {
control.onChange(value);
resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider);
}}
>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => (
<AntdSelect.Option key={providerEnum} value={providerEnum}>
<div className="flex items-center space-x-2">
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
<span>{providerDisplayName}</span>
</div>
</AntdSelect.Option>
))}
</AntdSelect>
)}
</MountedFormField>
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
</Tooltip>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
<div>
<Button onClick={closeAndReset} style={{ marginRight: 10 }}>
Cancel
</Button>
<Button htmlType="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
</div>
</div>
</Form>
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
</Tooltip>
<div>
<Button onClick={closeAndReset} style={{ marginRight: 10 }}>
Cancel
</Button>
<Button htmlType="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
</div>
</div>
</form>
</MountedFormProvider>
</FormProvider>
</Modal>
);
}

View file

@ -0,0 +1,24 @@
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import {
MountedFormProvider,
useMountRegistry,
type MountedFormValues,
} from "@/components/common_components/MountedFormField";
interface MountedFormHostProps {
defaultValues?: MountedFormValues;
children: React.ReactNode;
}
export const MountedFormHost: React.FC<MountedFormHostProps> = ({ defaultValues, children }) => {
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues });
const registry = useMountRegistry();
return (
<FormProvider {...form}>
<MountedFormProvider value={{ control: form.control, registry }}>{children}</MountedFormProvider>
</FormProvider>
);
};