mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
parent
963c7fb0d4
commit
5a899f596b
17 changed files with 1151 additions and 750 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = "example-name"</code>
|
||||
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">model = "example-name"</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
ui/litellm-dashboard/tests/mounted-form-host.tsx
Normal file
24
ui/litellm-dashboard/tests/mounted-form-host.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue