From 0ab172575703387896d998c108bd32e6ca4bf57f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 18:19:09 -0700 Subject: [PATCH] refactor(ui): migrate the model and router settings pages off antd (#37523) * refactor(ui): migrate the model and router settings pages off antd Converts the add model flow, credential panels, model settings and router settings onto the shadcn primitives, moves the mapping table onto the shared DataTable, and drops the dead uploadProps prop chain that only existed to carry antd's UploadProps type. * fix(ui): split comma-separated custom technical keywords into one term each --- ui/litellm-dashboard/eslint-suppressions.json | 131 ------- .../panels/AddModelPanel.integration.test.tsx | 14 +- .../panels/AddModelPanel.tsx | 4 - .../panels/LlmCredentialsPanel.tsx | 12 +- .../vertexCredentialsUpload.test.ts | 47 --- .../vertexCredentialsUpload.ts | 37 -- .../src/components/ModelInfoEditForm.tsx | 27 +- .../Fallbacks/EditFallbacks.test.tsx | 17 +- .../Fallbacks/FallbackGroupConfig.tsx | 80 ++--- .../Fallbacks/FallbackSelectionForm.test.tsx | 18 +- .../Fallbacks/FallbackSelectionForm.tsx | 82 +++-- .../add_model/AdaptiveRoutingConfig.tsx | 110 +++--- .../add_model/AddModelForm.test.tsx | 40 ++- .../src/components/add_model/AddModelForm.tsx | 5 +- .../add_model/ClassificationMethodConfig.tsx | 294 ++++++++-------- .../add_model/ComplexityRouterConfig.test.tsx | 60 ++-- .../add_model/ComplexityRouterConfig.tsx | 319 +++++++++--------- .../add_model/EscalationKeywords.tsx | 26 +- .../components/add_model/KeywordTierRules.tsx | 138 ++++---- .../add_model/SemanticKeywordMatching.tsx | 50 +-- .../add_model/add_auto_router_tab.test.tsx | 95 +++--- .../add_model/add_auto_router_tab.tsx | 96 +++--- .../add_model/advanced_settings.tsx | 146 ++++---- .../conditional_public_model_name.tsx | 31 +- .../add_model/litellm_model_name.tsx | 36 +- .../provider_specific_fields.test.tsx | 65 +++- .../add_model/provider_specific_fields.tsx | 150 ++++---- .../edit_auto_router_modal.test.tsx | 22 +- .../model_add/CredentialModal.test.tsx | 14 +- .../components/model_add/CredentialModal.tsx | 5 +- .../model_add/CredentialsPanel.test.tsx | 5 +- .../components/model_add/CredentialsPanel.tsx | 10 +- .../ModelSettingsModal.test.tsx | 4 +- .../ModelSettingsModal/ModelSettingsModal.tsx | 30 +- .../src/components/model_info_view.test.tsx | 23 +- .../RouterSettingsForm.test.tsx | 26 +- .../RoutingStrategySelector.test.tsx | 53 ++- .../RoutingStrategySelector.tsx | 34 +- .../components/router_settings/index.test.tsx | 52 +-- .../components/shared/SearchSelect.test.tsx | 7 + .../shared/form/UtcDateTimeInput.tsx | 53 +++ .../src/components/ui/combobox.tsx | 3 +- 42 files changed, 1153 insertions(+), 1318 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts create mode 100644 ui/litellm-dashboard/src/components/shared/form/UtcDateTimeInput.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index cafdb2fa7dc..2d68ea2aa68 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -57,9 +57,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 1 } @@ -199,11 +196,6 @@ "count": 1 } }, - "src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": { "no-nested-ternary": { "count": 3 @@ -550,11 +542,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { "no-nested-ternary": { "count": 1 @@ -570,11 +557,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -782,11 +764,6 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": { "local/no-complex-jsx-arrow": { "count": 2 @@ -987,9 +964,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 1 } @@ -1257,11 +1231,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { "no-nested-ternary": { "count": 2 @@ -1400,9 +1369,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1437,18 +1403,7 @@ "count": 1 } }, - "src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1488,11 +1443,6 @@ "count": 1 } }, - "src/components/UsagePage/components/EntityUsage/TopKeyView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/UsagePage/utils/value_formatters.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1511,11 +1461,6 @@ "count": 1 } }, - "src/components/add_model/AddModelForm.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/add_model/AddModelForm.tsx": { "local/no-complex-jsx-arrow": { "count": 1 @@ -1548,9 +1493,6 @@ "src/components/add_model/advanced_settings.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/add_model/auto_router_connection_test.tsx": { @@ -1570,9 +1512,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1593,9 +1532,6 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/add_model/model_connection_test.tsx": { @@ -1613,9 +1549,6 @@ "no-nested-ternary": { "count": 3 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 3 } @@ -1625,15 +1558,7 @@ "count": 1 } }, - "src/components/agent_management/AgentSelector.test.tsx": { - "react/display-name": { - "count": 1 - } - }, "src/components/agent_management/AgentSelector.tsx": { - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 } @@ -1724,11 +1649,6 @@ "count": 1 } }, - "src/components/common_components/AccessGroupSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/DeleteResourceModal.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1739,16 +1659,6 @@ "count": 1 } }, - "src/components/common_components/MetadataKeyValueFields.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/MetadataKeyValueFields.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/ModelAliasManager.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1759,11 +1669,6 @@ "count": 1 } }, - "src/components/common_components/RateLimitTypeFormItem.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/budget_duration_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1772,9 +1677,6 @@ "src/components/common_components/check_openapi_schema.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/common_components/fetch_teams.tsx": { @@ -1845,16 +1747,6 @@ "count": 1 } }, - "src/components/key_team_helpers/BudgetFallbacksEditor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/key_team_helpers/BudgetWindowsEditor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/key_team_helpers/fetch_available_models_team_key.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1953,11 +1845,6 @@ "count": 1 } }, - "src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2111,11 +1998,6 @@ "count": 1 } }, - "src/components/router_settings/RoutingStrategySelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/router_settings/index.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2264,11 +2146,6 @@ "count": 1 } }, - "src/components/team/LoggingSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/team/TeamInfo.tsx": { "max-lines": { "count": 1 @@ -2276,9 +2153,6 @@ "no-nested-ternary": { "count": 3 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2614,11 +2488,6 @@ "count": 2 } }, - "src/contexts/AntdGlobalProvider.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/contexts/AuthContext.tsx": { "react-hooks/set-state-in-effect": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index b762f006261..19e1e3aa8bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -219,7 +219,7 @@ describe("AddModelPanel submit payload contract", () => { const { user, openAdvanced, fillRequired, submit } = await setup(); await fillRequired(); await openAdvanced(); - await user.click(screen.getByLabelText("Custom Pricing")); + await user.click(screen.getByRole("switch", { name: "Custom Pricing" })); await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3"); await user.type(screen.getByLabelText("Output Cost (per 1M tokens)"), "9"); await submit(); @@ -241,7 +241,7 @@ describe("AddModelPanel submit payload contract", () => { const { user, openAdvanced, fillRequired, submit } = await setup(); await fillRequired(); await openAdvanced(); - await user.click(screen.getByLabelText("Cache Control Injection Points")); + await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" })); await screen.findByText("Add Injection Point"); await submit(); @@ -260,7 +260,7 @@ describe("AddModelPanel submit payload contract", () => { const { user, openAdvanced, fillRequired, submit } = await setup(); await fillRequired(); await openAdvanced(); - await user.click(screen.getByLabelText("Cache Control Injection Points")); + await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" })); await screen.findByText("Add Injection Point"); await user.click(screen.getByText("Select a role")); await user.click(await screen.findByText("System")); @@ -385,7 +385,7 @@ describe("AddModelPanel behaviours the removed Advanced Settings form instance n const { user, openAdvanced, fillRequired, submit } = await setup(); await fillRequired(); await openAdvanced(); - await user.click(screen.getByLabelText("Use in pass through routes")); + await user.click(screen.getByRole("switch", { name: "Use in pass through routes" })); expect(screen.getByLabelText("LiteLLM Params")).toHaveValue(""); await submit(); @@ -401,11 +401,11 @@ describe("AddModelPanel behaviours the removed Advanced Settings form instance n const { user, openAdvanced, fillRequired, submit } = await setup(); await fillRequired(); await openAdvanced(); - await user.click(screen.getByLabelText("Custom Pricing")); + await user.click(screen.getByRole("switch", { name: "Custom Pricing" })); await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3"); - await user.click(screen.getByLabelText("Custom Pricing")); + await user.click(screen.getByRole("switch", { name: "Custom Pricing" })); await waitFor(() => expect(screen.queryByLabelText("Input Cost (per 1M tokens)")).not.toBeInTheDocument()); - await user.click(screen.getByLabelText("Custom Pricing")); + await user.click(screen.getByRole("switch", { name: "Custom Pricing" })); expect(await screen.findByLabelText("Input Cost (per 1M tokens)")).toHaveValue("3"); await submit(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 59d4f95c038..1443c065d9b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -15,7 +15,6 @@ 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 }; @@ -60,9 +59,6 @@ export default function AddModelPanel() { providerModels={providerModels} setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))} getPlaceholder={getPlaceholder} - uploadProps={vertexCredentialsUploadProps({ - setFieldsValue: (values) => form.setValue("vertex_credentials", values.vertex_credentials), - })} showAdvancedSettings={showAdvancedSettings} setShowAdvancedSettings={setShowAdvancedSettings} teams={teams ?? null} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx index 7251da7c3c3..c71ed177416 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx @@ -1,17 +1,7 @@ "use client"; -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 = useForm(); - return ( - form.setValue("vertex_credentials", values.vertex_credentials), - })} - /> - ); + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.test.ts deleted file mode 100644 index e02e4974353..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { toast } from "@/lib/toast"; - -import { vertexCredentialsUploadProps } from "./vertexCredentialsUpload"; - -const makeForm = () => ({ setFieldsValue: vi.fn() }); - -describe("vertexCredentialsUploadProps", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("reads a JSON credential file into the vertex_credentials field without uploading it", async () => { - const form = makeForm(); - const props = vertexCredentialsUploadProps(form as never); - const file = new File(['{"project_id":"example"}'], "vertex.json", { type: "application/json" }); - - expect(props.beforeUpload?.(file as never, [file] as never)).toBe(false); - - await waitFor(() => { - expect(form.setFieldsValue).toHaveBeenCalledWith({ vertex_credentials: '{"project_id":"example"}' }); - }); - }); - - it("ignores non-JSON files", async () => { - const form = makeForm(); - const props = vertexCredentialsUploadProps(form as never); - const file = new File(["not json"], "vertex.txt", { type: "text/plain" }); - - expect(props.beforeUpload?.(file as never, [file] as never)).toBe(false); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(form.setFieldsValue).not.toHaveBeenCalled(); - }); - - it("reports completed and failed upload states", () => { - const props = vertexCredentialsUploadProps(makeForm() as never); - - props.onChange?.({ file: { name: "vertex.json", status: "done" } } as never); - props.onChange?.({ file: { name: "vertex.json", status: "error" } } as never); - - expect(toast.success).toHaveBeenCalledWith("vertex.json file uploaded successfully"); - expect(toast.fromError).toHaveBeenCalledWith("vertex.json file upload failed."); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts deleted file mode 100644 index f658ec88c1c..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentProps } from "react"; - -import { toast } from "@/lib/toast"; -import type CredentialsPanel from "@/components/model_add/CredentialsPanel"; - -interface VertexCredentialsForm { - setFieldsValue: (values: { vertex_credentials: string }) => void; -} - -type UploadProps = ComponentProps["uploadProps"]; - -export function vertexCredentialsUploadProps(form: VertexCredentialsForm): UploadProps { - return { - name: "file", - accept: ".json", - pastable: false, - beforeUpload: (file) => { - if (file.type === "application/json") { - const reader = new FileReader(); - reader.onload = (event) => { - if (event.target) { - form.setFieldsValue({ vertex_credentials: event.target.result as string }); - } - }; - reader.readAsText(file); - } - return false; - }, - onChange(info) { - if (info.file.status === "done") { - toast.success(`${info.file.name} file uploaded successfully`); - } else if (info.file.status === "error") { - toast.fromError(`${info.file.name} file upload failed.`); - } - }, - }; -} diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index fff732fe270..d56e65237eb 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -1,8 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -// eslint-disable-next-line no-restricted-imports -- the dashboard has no shadcn date-time picker; the PTU window fields need one -import { DatePicker } from "antd"; import { CircleHelp } from "lucide-react"; import type { Dayjs } from "dayjs"; import * as React from "react"; @@ -11,6 +9,7 @@ import { z } from "zod/v4"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; import { FormField } from "@/components/shared/form/FormField"; +import { UtcDateTimeInput } from "@/components/shared/form/UtcDateTimeInput"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -292,9 +291,16 @@ const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
); -const FieldLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -

{children}

-); +const FIELD_LABEL_CLASS = "text-sm font-medium text-foreground"; + +const FieldLabel: React.FC<{ htmlFor?: string; children: React.ReactNode }> = ({ htmlFor, children }) => + htmlFor === undefined ? ( +

{children}

+ ) : ( + + ); const Hint: React.FC<{ text: string }> = ({ text }) => ( @@ -463,13 +469,14 @@ const ModelInfoEditForm: React.FC = ({ {ptuCostAttributionEnabled && PTU_EDIT_FIELDS.map((ptuField) => (
- {ptuField.label} + {ptuField.label} {isEditing ? ( {({ value, onChange, ...control }) => ptuField.input === "number" ? ( = ({ min={ptuField.isCount ? 1 : 0} /> ) : ( - ) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx index 225c308af91..c0db38eb187 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import EditFallbacks, { Fallbacks } from "./EditFallbacks"; @@ -49,10 +49,9 @@ describe("EditFallbacks", () => { it("prefills the existing fallback chain for the primary model", async () => { setup(); - await waitFor(() => { - expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); - expect(screen.getByText("claude-3-opus")).toBeInTheDocument(); - }); + const chain = await screen.findByRole("list", { name: "Fallback chain" }); + expect(within(chain).getByText("gpt-3.5-turbo")).toBeInTheDocument(); + expect(within(chain).getByText("claude-3-opus")).toBeInTheDocument(); }); it("removes a fallback model and saves only the edited entry", async () => { @@ -61,8 +60,8 @@ describe("EditFallbacks", () => { const onClose = vi.fn(); setup({ onChange, onClose }); - await screen.findByText("gpt-3.5-turbo"); - await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo")); + const chain = await screen.findByRole("list", { name: "Fallback chain" }); + await user.click(within(chain).getByRole("button", { name: "Remove gpt-3.5-turbo" })); await user.click(screen.getByRole("button", { name: /save changes/i })); @@ -77,8 +76,8 @@ describe("EditFallbacks", () => { const onChange = vi.fn().mockResolvedValue(undefined); setup({ fallbackEntry: { "gpt-4": ["gpt-3.5-turbo"] }, onChange }); - await screen.findByText("gpt-3.5-turbo"); - await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo")); + const chain = await screen.findByRole("list", { name: "Fallback chain" }); + await user.click(within(chain).getByRole("button", { name: "Remove gpt-3.5-turbo" })); const saveButton = screen.getByRole("button", { name: /save changes/i }); expect(saveButton).toBeDisabled(); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx index e3a36f4fbf2..129f509dcd9 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx @@ -3,10 +3,10 @@ * Handles primary model selection and fallback chain configuration */ -import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Select } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { AlertCircle, ArrowDown, X } from "lucide-react"; -import React from "react"; +import React, { useId } from "react"; export interface FallbackGroup { id: string; @@ -64,25 +64,24 @@ export function FallbackGroupConfig({ }; const canAddMoreFallbacks = group.fallbackModels.length < maxFallbacks; + const primaryModelInputId = useId(); return (
{/* Primary Model Section */}
-
) : ( - group.fallbackModels.map((modelValue, index) => { - return ( -
+ {group.fallbackModels.map((modelValue, index) => ( +
  • @@ -182,15 +154,15 @@ export function FallbackGroupConfig({ -
  • - ); - }) + + ))} + )}
    diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx index ac9b4d98aed..738040149f0 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { FallbackSelectionForm } from "./FallbackSelectionForm"; @@ -69,7 +69,7 @@ describe("FallbackSelectionForm", () => { , ); - const addTabButton = screen.getByRole("button", { name: /add tab/i }); + const addTabButton = screen.getByRole("button", { name: /add fallback group/i }); await user.click(addTabButton); expect(mockOnGroupsChange).toHaveBeenCalledTimes(1); @@ -98,7 +98,7 @@ describe("FallbackSelectionForm", () => { maxGroups={5} />, ); - expect(screen.queryByRole("button", { name: /add tab/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /add fallback group/i })).not.toBeInTheDocument(); }); it("should show add tab button when below maxGroups with custom maxGroups", () => { @@ -111,7 +111,7 @@ describe("FallbackSelectionForm", () => { maxGroups={3} />, ); - expect(screen.getByRole("button", { name: /add tab/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add fallback group/i })).toBeInTheDocument(); }); it("should call onGroupsChange when a group is removed", async () => { @@ -124,7 +124,8 @@ describe("FallbackSelectionForm", () => { , ); - const removeButtons = screen.getAllByRole("tab", { name: "remove" }); + const removeButtons = screen.getAllByRole("button", { name: /^remove /i }); + expect(removeButtons).toHaveLength(2); await user.click(removeButtons[0]); expect(mockOnGroupsChange).toHaveBeenCalledTimes(1); @@ -139,7 +140,7 @@ describe("FallbackSelectionForm", () => { render( , ); - expect(screen.getByText("Select primary model")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: /primary model/i })).toHaveValue(""); expect(screen.getByText("Primary Model")).toBeInTheDocument(); }); @@ -150,7 +151,8 @@ describe("FallbackSelectionForm", () => { ); expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument(); expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); - expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + const chain = screen.getByRole("list", { name: "Fallback chain" }); + expect(within(chain).getByText("gpt-3.5-turbo")).toBeInTheDocument(); }); it("should not add group when add button clicked at maxGroups", () => { @@ -168,7 +170,7 @@ describe("FallbackSelectionForm", () => { />, ); - expect(screen.queryByRole("button", { name: /add tab/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /add fallback group/i })).not.toBeInTheDocument(); expect(mockOnGroupsChange).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx index 10bbcd9ba3b..2639171fbe8 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx @@ -5,8 +5,8 @@ */ import { Button } from "@/components/ui/button"; -import { Tabs } from "antd"; -import { Plus } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus, X } from "lucide-react"; import React, { useEffect, useState } from "react"; import { toast } from "@/lib/toast"; import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; @@ -76,23 +76,8 @@ export function FallbackSelectionForm({ onGroupsChange(newGroups); }; - // Generate tab items - const items = groups.map((group, index) => { - const label = group.primaryModel ? group.primaryModel : `Group ${index + 1}`; - return { - key: group.id, - label: label, - closable: groups.length > 1, // Only allow closing if there's more than 1 group - children: ( - - ), - }; - }); + const groupLabel = (group: FallbackGroup, index: number) => + group.primaryModel ? group.primaryModel : `Group ${index + 1}`; if (groups.length === 0) { return ( @@ -107,22 +92,47 @@ export function FallbackSelectionForm({ } return ( - { - if (action === "add") handleAddGroup(); - else if (action === "remove" && groups.length > 1) { - handleRemoveGroup(targetKey as string); - } - }} - items={items} - className="fallback-tabs" - tabBarStyle={{ - marginBottom: 0, - }} - hideAdd={groups.length >= maxGroups} - /> + +
    + + {groups.map((group, index) => ( +
    + 1 ? "pr-9" : "pr-4"}`} + > + {groupLabel(group, index)} + + {groups.length > 1 && ( + + )} +
    + ))} +
    + {groups.length < maxGroups && ( + + )} +
    + {groups.map((group) => ( + + + + ))} +
    ); } diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx index 720b6f88db3..137ef297d56 100644 --- a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx @@ -1,4 +1,9 @@ -import { Card, InputNumber, Radio, Slider, Space, Switch, Typography } from "antd"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; import React from "react"; import { AdaptiveEligible, @@ -7,8 +12,6 @@ import { DEFAULT_TIER_DISTANCE_PENALTY, } from "./ComplexityRouterConfig"; -const { Text } = Typography; - interface AdaptiveRoutingConfigProps { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; @@ -45,84 +48,91 @@ const AdaptiveRoutingConfig: React.FC = ({ value, on return ( <> -
    - - Enable adaptive bandit selection -
    - + + When disabled, each request always uses the model assigned to its classified tier. - + - - How Adaptive Routing Works - - - It learns from how each conversation actually goes: does the user have to rephrase or correct the model, does - it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined with - cost, this live feedback shifts future routing toward the models that are actually working well, and improves - as more conversations come in. Until there's enough feedback, it defaults to the classified tier's - model. - + + How Adaptive Routing Works + + It learns from how each conversation actually goes: does the user have to rephrase or correct the model, + does it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined + with cost, this live feedback shifts future routing toward the models that are actually working well, and + improves as more conversations come in. Until there's enough feedback, it defaults to the classified + tier's model. + + {value.adaptive && (
    - + Quality vs. Cost ({Math.round(adaptiveWeights.quality * 100)}% quality /{" "} {Math.round(adaptiveWeights.cost * 100)}% cost) - + `${v}% quality / ${100 - (v ?? 0)}% cost` }} + value={[Math.round(adaptiveWeights.quality * 100)]} + onValueChange={(next) => handleQualityWeightChange(Array.isArray(next) ? next[0] : next)} /> - + Higher quality weight favors more capable (pricier) models; higher cost weight favors cheaper models when the bandit has feedback to act on. Recommended: 30% quality / 70% cost split. - +
    - - Eligible Model Pool - - Eligible Model Pool + handleAdaptiveEligibleChange(e.target.value)} + onValueChange={(eligible: unknown) => handleAdaptiveEligibleChange(eligible as AdaptiveEligible)} className="w-full" > - - - All tiers (soft floor){" "} - — router can pick across tiers, depending on the best fit for the prompt - - - Classified tier only{" "} - — router can only pick models within tier - - - +
    + + +
    +
    {adaptiveEligible === "all" && (
    - - Tier Distance Penalty - - Tier Distance Penalty + + handleTierDistancePenaltyChange(event.target.value === "" ? null : event.target.valueAsNumber) + } min={0} step={0.1} - style={{ width: "100%" }} + className="w-full" /> - + Score penalty applied per tier-step away from the classified tier. - +
    )}
    diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx index 26bd9af94a8..340fd811e97 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx @@ -1,6 +1,5 @@ import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -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"; @@ -157,11 +156,6 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi }, ]; - const uploadProps: UploadProps = { - beforeUpload: () => false, - showUploadList: false, - }; - return { form, registry, @@ -176,7 +170,6 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi showAdvancedSettings: false, teams, credentials, - uploadProps, userRole, userId, }; @@ -318,6 +311,35 @@ describe("AddModelForm", () => { expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument(); }); + describe("the enterprise gate on the Team-BYOK switch", () => { + const renderForm = async (premiumUser: boolean) => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", premiumUser)); + renderWithProviders(); + return screen.findByRole("switch", { name: "Team-BYOK Model" }); + }; + + it("explains the gate on hover even though the switch it sits on is disabled", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + const teamOnlySwitch = await renderForm(false); + expect(teamOnlySwitch).toHaveAttribute("aria-disabled", "true"); + + await user.hover(teamOnlySwitch); + + expect(await screen.findByText(/enterprise-only feature/)).toBeInTheDocument(); + }); + + it("says nothing on hover once the user is premium", async () => { + const user = userEvent.setup(); + const teamOnlySwitch = await renderForm(true); + expect(teamOnlySwitch).not.toHaveAttribute("aria-disabled", "true"); + + await user.hover(teamOnlySwitch); + + expect(screen.queryByText(/enterprise-only feature/)).not.toBeInTheDocument(); + }); + }); + describe("cache control bindings reach the parent form store", () => { const renderWithForm = async () => { const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); @@ -331,11 +353,11 @@ describe("AddModelForm", () => { user, openCacheControl: async () => { await user.click(await screen.findByText("Advanced Settings")); - await user.click(screen.getByLabelText("Cache Control Injection Points")); + await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" })); await screen.findByText("Add Injection Point"); }, closeCacheControl: async () => { - await user.click(screen.getByLabelText("Cache Control Injection Points")); + await user.click(screen.getByRole("switch", { name: "Cache Control Injection Points" })); await waitFor(() => expect(screen.queryByText("Add Injection Point")).not.toBeInTheDocument()); }, mountedValues: async (): Promise> => props.mountedValues(), diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 65efd6aed51..b6dddf43588 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -9,7 +9,6 @@ import { Select as AntdSelect, Card, Col, Row, Tooltip, Typography } from "antd" import { Info } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; -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"; @@ -44,7 +43,6 @@ interface AddModelFormProps { providerModels: string[]; setProviderModelsFn: (provider: Providers) => void; getPlaceholder: (provider: Providers) => string; - uploadProps: UploadProps; showAdvancedSettings: boolean; setShowAdvancedSettings: (show: boolean) => void; teams: Team[] | null; @@ -71,7 +69,6 @@ const AddModelForm: React.FC = ({ providerModels, setProviderModelsFn, getPlaceholder, - uploadProps, showAdvancedSettings, setShowAdvancedSettings, teams, @@ -311,7 +308,7 @@ const AddModelForm: React.FC = ({ OR
    - + )}
    diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index 019df7d4fa9..3c384aed1b3 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -1,6 +1,13 @@ import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Select as AntdSelect, Card, InputNumber, Radio, Space, Switch, Typography } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; import React from "react"; import ClassifierPromptEditor from "./ClassifierPromptEditor"; import HeuristicScoringConfig from "./HeuristicScoringConfig"; @@ -21,8 +28,6 @@ import { effectiveTierLabel, } from "./ComplexityRouterConfig"; -const { Text } = Typography; - const DEFAULT_SCORING_EXPLANATION = "The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical " + "terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the tier:"; @@ -87,36 +92,35 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = return ( - - How Classification Works - - - {scoringExplanation(value)} - - {ranges && ( -
      -
    • - {effectiveTierLabel("SIMPLE", value.tier_labels)}: Score < {ranges.simpleMedium} -
    • -
    • - {effectiveTierLabel("MEDIUM", value.tier_labels)}: Score {ranges.simpleMedium} -{" "} - {ranges.mediumComplex} -
    • -
    • - {effectiveTierLabel("COMPLEX", value.tier_labels)}: Score {ranges.mediumComplex} -{" "} - {ranges.complexReasoning} -
    • -
    • - {effectiveTierLabel("REASONING", value.tier_labels)}: Score > {ranges.complexReasoning}{" "} - (or 2+ reasoning markers with a score of at least {ranges.reasoningOverrideFloor}) -
    • -
    - )} - {!ranges && isError && ( - - The tier score ranges could not be loaded from the proxy. - - )} + + How Classification Works + {scoringExplanation(value)} + {ranges && ( +
      +
    • + {effectiveTierLabel("SIMPLE", value.tier_labels)}: Score < {ranges.simpleMedium} +
    • +
    • + {effectiveTierLabel("MEDIUM", value.tier_labels)}: Score {ranges.simpleMedium} -{" "} + {ranges.mediumComplex} +
    • +
    • + {effectiveTierLabel("COMPLEX", value.tier_labels)}: Score {ranges.mediumComplex} -{" "} + {ranges.complexReasoning} +
    • +
    • + {effectiveTierLabel("REASONING", value.tier_labels)}: Score >{" "} + {ranges.complexReasoning} (or 2+ reasoning markers with a score of at least{" "} + {ranges.reasoningOverrideFloor}) +
    • +
    + )} + {!ranges && isError && ( + + The tier score ranges could not be loaded from the proxy. + + )} +
    ); }; @@ -247,61 +251,64 @@ const ClassificationMethodConfig: React.FC = ({ return ( <> - handleClassifierTypeChange(e.target.value)} + onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)} className="w-full" > - - - Heuristic{" "} - (default) — rule-based scoring, no API calls, <1ms latency - - - LLM Classifier{" "} - — use a model to decide the tier (e.g. a small/fast model) - - - +
    + + +
    + {value.classifier_type === "llm" && (
    - - Classifier Model - - Classifier Model + - {classifierModelMissing && ( - - A classifier model is required - - )} + {classifierModelMissing && A classifier model is required}
    - - Timeout (ms) - - Timeout (ms) + + handleClassifierTimeoutChange(event.target.value === "" ? null : event.target.valueAsNumber) + } min={1} - style={{ width: "100%" }} + className="w-full" /> - + How long the classifier call has before it fails and the fallback below takes over. - +
    - Classification Rubric + Classification Rubric @@ -310,28 +317,37 @@ const ClassificationMethodConfig: React.FC = ({ content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined} className="w-full" > - ({ + - + {usesCustomPrompt ? "Not in use: the custom prompt below is the classifier's entire rubric." : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description} - +
    - - Classifier Prompt - + Classifier Prompt = ({ />
    - - If the classifier fails - - If the classifier fails + handleClassifierFallbackChange(e.target.value)} + onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)} > - - - Score with the heuristic{" "} - — right when the classifier grades complexity too - - +
    + + +
    +
    + Applies when the classifier call errors, times out, or returns an unparseable response. - +
    - - Context Window Size - - Context Window Size + + handleClassifierContextWindowSizeChange(event.target.value === "" ? null : event.target.valueAsNumber) + } min={0} - style={{ width: "100%" }} + className="w-full" /> - + Number of prior user turns (tool output and harness reminders excluded) sent to the classifier as context, so a referring follow-up like "now do the same for the streaming path" is classified against what it refers to. Set to 0 to send only the current message. - +
    - - Context Per-Turn Character Limit - - Context Per-Turn Character Limit + + handleClassifierContextPerTurnCharsChange(event.target.value === "" ? null : event.target.valueAsNumber) + } min={1} - style={{ width: "100%" }} + className="w-full" /> - - Prior turns longer than this are truncated. - + Prior turns longer than this are truncated.
    - Include Assistant Turns + Include Assistant Turns
    - + Let the classifier read the assistant's replies, so difficulty the model stated rather than the user stays visible: a plan the assistant calls complex, approved with "yes", is classified on the work being approved. Context Window Size then counts the last N turns across both roles rather than the last N user turns. - +
    )} @@ -429,25 +449,29 @@ const ClassificationMethodConfig: React.FC = ({ {value.classifier_type === "heuristic" && (
    - Custom Technical Keywords + Custom Technical Keywords
    - + Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension. (e.g., udp, kafka, terraform). - - + ({ label: keyword, value: keyword }))} value={customTechnicalKeywords ?? []} - onChange={(keywords: string[]) => onCustomTechnicalKeywordsChange?.(keywords)} - placeholder="Type a keyword and press Enter, or paste a comma-separated list" - tokenSeparators={[","]} - open={false} - suffixIcon={null} - style={{ width: "100%" }} - allowClear + onValueChange={(keywords: string[]) => + onCustomTechnicalKeywordsChange?.( + Array.from( + new Set(keywords.flatMap((keyword) => keyword.split(",").map((part) => part.trim())).filter(Boolean)), + ), + ) + } + placeholder="Type a keyword and press Enter" + emptyText="Type to add a keyword" + allowCustomValues + className="w-full" />
    )} diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index d0fda9962e5..5f5ae703b0e 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -280,11 +280,28 @@ describe("ComplexityRouterConfig", () => { ); fireEvent.click(screen.getByText("Advanced: Classification Method")); const keywordsSection = screen.getByText("Custom Technical Keywords").closest("div")?.parentElement as HTMLElement; - const input = within(keywordsSection).getByRole("combobox"); - fireEvent.change(input, { target: { value: "udp," } }); + await user.type(within(keywordsSection).getByRole("combobox"), "udp"); + await user.click(await screen.findByText('Create "udp"')); expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp"]); }); + it("splits a comma-separated keyword entry into one keyword per token", async () => { + const user = userEvent.setup(); + const onCustomTechnicalKeywordsChange = vi.fn(); + renderWithProviders( + , + ); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + const keywordsSection = screen.getByText("Custom Technical Keywords").closest("div")?.parentElement as HTMLElement; + await user.type(within(keywordsSection).getByRole("combobox"), "udp, kafka ,terraform"); + await user.click(await screen.findByText('Create "udp, kafka ,terraform"')); + expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp", "kafka", "terraform"]); + }); + it("should render an empty state when no keyword tier rules exist", () => { renderWithProviders(); fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); @@ -314,9 +331,7 @@ describe("ComplexityRouterConfig", () => { expect(newRules[0]).toMatchObject({ keywords: [], tier: "COMPLEX" }); }); - // The dropdown is closed, so antd has nothing for Enter to select and the word would only land - // on blur. Submitting used to provide that blur; it no longer can while the row reads as empty. - it("commits a typed keyword on Enter, with the dropdown closed", async () => { + it("commits a typed keyword to the rule it was typed into", async () => { const user = userEvent.setup(); const onKeywordTierRulesChange = vi.fn(); renderWithProviders( @@ -329,7 +344,8 @@ describe("ComplexityRouterConfig", () => { fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); const field = screen.getByText("Keywords 1").closest("div") as HTMLElement; - await user.type(within(field).getByRole("combobox"), "invoice{enter}"); + await user.type(within(field).getByRole("combobox"), "invoice"); + await user.click(await screen.findByText('Create "invoice"')); expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "rule-1", keywords: ["invoice"], tier: "COMPLEX" }]); }); @@ -482,7 +498,7 @@ describe("ComplexityRouterConfig classifier fallback", () => { }; renderWithProviders(); fireEvent.click(screen.getByText("Advanced: Classification Method")); - expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeDisabled(); + expect(screen.getByRole("radio", { name: /Route to the default model/ })).toHaveAttribute("aria-disabled", "true"); }); it("hides the fallback choice for the heuristic classifier, which has nothing to fall back from", () => { @@ -584,8 +600,8 @@ describe("ComplexityRouterConfig classifier rubric", () => { it("records the chat preset the operator picks", async () => { const onChange = openClassificationPanel(llmValue); - fireEvent.mouseDown(screen.getByRole("combobox", { name: "Classification Rubric" })); - await userEvent.click(await screen.findByTitle("Chat")); + await userEvent.click(screen.getByRole("combobox", { name: "Classification Rubric" })); + await userEvent.click(await screen.findByRole("option", { name: "Chat" })); expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ classifier_llm_config: expect.objectContaining({ classification_rubric: "chat" }) }), ); @@ -678,7 +694,7 @@ describe("ComplexityRouterConfig tier labels", () => { />, ); fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); - expect(screen.getByTitle("Deep")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })).toHaveTextContent("Deep"); }); }); @@ -716,7 +732,7 @@ describe("ComplexityRouterConfig default model", () => { it("shows what the tiers currently imply, so an untouched router still names its default", () => { renderWithProviders(); - expect(screen.getByText("Derived from tiers: gpt-3.5-turbo")).toBeInTheDocument(); + expect(getDefaultModelSelect()).toHaveAttribute("placeholder", "Derived from tiers: gpt-3.5-turbo"); }); it("asks for a model rather than naming a derived one when no tier holds one", () => { @@ -725,7 +741,7 @@ describe("ComplexityRouterConfig default model", () => { tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, }; renderWithProviders(); - expect(screen.getByText("Add a model to the Simple or Medium tier")).toBeInTheDocument(); + expect(getDefaultModelSelect()).toHaveAttribute("placeholder", "Add a model to the Simple or Medium tier"); }); it("records a pinned model", async () => { @@ -734,7 +750,7 @@ describe("ComplexityRouterConfig default model", () => { renderWithProviders(); await user.click(getDefaultModelSelect()); - await user.click((await screen.findAllByTitle("claude-3-opus")).slice(-1)[0]); + await user.click(await screen.findByRole("option", { name: "claude-3-opus" })); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: "claude-3-opus" })); }); @@ -745,8 +761,7 @@ describe("ComplexityRouterConfig default model", () => { const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" }; renderWithProviders(); - // eslint-disable-next-line local/no-antd-class-selectors -- antd marks the clear affordance aria-hidden, so no accessible query reaches it - await user.click(document.querySelector(".ant-select-clear") as HTMLElement); + await user.click(screen.getByRole("button", { name: "Clear" })); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: undefined })); }); @@ -754,10 +769,7 @@ describe("ComplexityRouterConfig default model", () => { it("shows a pinned model as the selection instead of the tier-derived one", () => { const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" }; renderWithProviders(); - expect( - // eslint-disable-next-line local/no-antd-class-selectors -- the tier selects show the same model as a tag, so the assertion has to scope to this select's root, which antd exposes only as a class - within(getDefaultModelSelect().closest(".ant-select") as HTMLElement).getByTitle("claude-3-opus"), - ).toBeInTheDocument(); + expect(getDefaultModelSelect()).toHaveValue("claude-3-opus"); }); it("unlocks the default model fallback on a pin alone, with no tier to derive from", () => { @@ -770,7 +782,7 @@ describe("ComplexityRouterConfig default model", () => { }; renderWithProviders(); fireEvent.click(screen.getByText("Advanced: Classification Method")); - expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeEnabled(); + expect(screen.getByRole("radio", { name: /Route to the default model/ })).not.toHaveAttribute("aria-disabled"); }); it("names the resolved default on the fallback option, so the destination is not a guess", () => { @@ -827,9 +839,9 @@ describe("plan-mode override", () => { />, ); openPanel(); - fireEvent.mouseDown(await screen.findByRole("combobox", { name: "Plan-mode minimum tier" })); - expect(await screen.findByTitle("Medium")).toBeInTheDocument(); - expect(screen.queryByTitle("Reasoning")).not.toBeInTheDocument(); + await userEvent.click(await screen.findByRole("combobox", { name: "Plan-mode minimum tier" })); + expect(await screen.findByRole("option", { name: "Medium" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Reasoning" })).not.toBeInTheDocument(); }); it("disables the toggle until some tier has models", async () => { @@ -840,6 +852,6 @@ describe("plan-mode override", () => { />, ); openPanel(); - expect(await screen.findByRole("switch", { name: switchName })).toBeDisabled(); + expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true"); }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index ad5260c2c9c..ab6b1d401ce 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -1,6 +1,13 @@ -import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Select as AntdSelect, Card, Collapse, Divider, Input, Space, Switch, Typography } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ChevronRight, Info, X } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { Separator } from "@/components/ui/separator"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; @@ -13,8 +20,6 @@ import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from export type { DimensionWeights, TierBoundaries, TokenThresholds }; -const { Text } = Typography; - export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000; export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5; export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3; @@ -218,6 +223,9 @@ const ComplexityRouterConfig: React.FC = ({ showValidationErrors = false, }) => { const planModeTiers = planModeEligibleTiers(value.tiers); + const planModeTierOptions = tierOptions(value.tier_labels).filter((option) => + (planModeTiers as string[]).includes(option.value), + ); const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers); const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); @@ -251,128 +259,119 @@ const ComplexityRouterConfig: React.FC = ({ return (
    - - - Complexity Tier Configuration - +
    +

    Complexity Tier Configuration

    - +
    - + The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier. - + - + Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names. {value.classifier_type === "llm" && " Your classifier model reads these names, so clearer ones can sharpen its choices."} - + - {TIER_KEYS.map((tier, index) => { - const tierInfo = TIER_DESCRIPTIONS[tier]; - const label = effectiveTierLabel(tier, value.tier_labels); - const tierMissing = showValidationErrors && value.tiers[tier].length === 0; - return ( -
    - {index > 0 && } -
    -
    - - {label} Tier - - - - - - Tier {index + 1} of {TIER_KEYS.length} · {tier} - + + {TIER_KEYS.map((tier, index) => { + const tierInfo = TIER_DESCRIPTIONS[tier]; + const label = effectiveTierLabel(tier, value.tier_labels); + const tierMissing = showValidationErrors && value.tiers[tier].length === 0; + return ( +
    + {index > 0 && } +
    +
    + {label} Tier + + + + + Tier {index + 1} of {TIER_KEYS.length} · {tier} + +
    + Examples: {tierInfo.examples} + + handleTierLabelChange(tier, event.target.value)} + placeholder={`Display name (default: ${tierInfo.label})`} + aria-label={`Display name for the ${tierInfo.label} tier`} + /> + {value.tier_labels?.[tier] && ( + + handleTierLabelChange(tier, "")} + > + + + + )} + + handleTierChange(tier, models)} + placeholder={`Select model(s) for ${label.toLowerCase()} queries`} + emptyText="No models found" + className={tierMissing ? "w-full border-destructive" : "w-full"} + /> + {value.tiers[tier].length > 1 && ( + + Multiple models selected — the router randomly picks among them per request (or Thompson-samples + within the pool when adaptive routing is on). + + )} + {tierMissing && The {label} tier is required}
    - - Examples: {tierInfo.examples} - - handleTierLabelChange(tier, event.target.value)} - placeholder={`Display name (default: ${tierInfo.label})`} - aria-label={`Display name for the ${tierInfo.label} tier`} - style={{ marginBottom: 8 }} - allowClear - /> - handleTierChange(tier, models)} - placeholder={`Select model(s) for ${label.toLowerCase()} queries`} - showSearch - style={{ width: "100%" }} - options={modelOptions} - status={tierMissing ? "error" : undefined} - /> - {value.tiers[tier].length > 1 && ( - - Multiple models selected — the router randomly picks among them per request (or Thompson-samples - within the pool when adaptive routing is on). - - )} - {tierMissing && ( - - The {label} tier is required - - )}
    -
    - ); - })} - + ); + })} + -
    -
    - - Default Model - - - - +
    +
    + Default Model + + + +
    + + + Used when the tier the request lands in has no model, and when the classifier fails with "Route to + the default model" selected. +
    - - - Used when the tier the request lands in has no model, and when the classifier fails with "Route to the - default model" selected. - -
    + - + - + {[ { key: "classifier", - label: ( - - Advanced: Classification Method - - ), + label: Advanced: Classification Method, children: ( = ({ }, { key: "adaptive", - label: ( - - Advanced: Adaptive Routing - - ), + label: Advanced: Adaptive Routing, children: , }, { key: "affinity", - label: ( - - Advanced: Affinity - - ), + label: Advanced: Affinity, children: ( <>
    onChange({ ...value, deployment_affinity: deploymentAffinity })} + onCheckedChange={(deploymentAffinity) => + onChange({ ...value, deployment_affinity: deploymentAffinity }) + } aria-label="Pin a session to one deployment per model group" /> - Pin a session to one deployment per model group + Pin a session to one deployment per model group
    - + Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to load-balance every turn. - +
    onChange({ ...value, session_affinity: sessionAffinity })} + onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })} aria-label="Pin a session to its first model" /> - Pin a session to its first model + Pin a session to its first model
    - + Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment. - + ), }, { key: "plan-mode", - label: ( - - Advanced: Plan-Mode Override - - ), + label: Advanced: Plan-Mode Override, children: ( <>
    + onCheckedChange={(enabled) => onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined }) } aria-label="Route plan-mode requests to a minimum tier" /> - Route plan-mode requests to a minimum tier + Route plan-mode requests to a minimum tier
    - + Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier still wins when it picks higher, and the override only lasts while plan mode is active. {planModeTiers.length === 0 && " Add models to a tier to enable this."} - + {value.plan_mode_min_tier !== undefined && (
    - - (planModeTiers as string[]).includes(option.value), - )} - onChange={(tier: string) => onChange({ ...value, plan_mode_min_tier: tier })} - /> + onValueChange={(tier: string | null) => tier && onChange({ ...value, plan_mode_min_tier: tier })} + > + + + + + {planModeTierOptions.map((option) => ( + + {option.label} + + ))} + +
    )} @@ -473,23 +469,22 @@ const ComplexityRouterConfig: React.FC = ({ }, { key: "response", - label: ( - - Advanced: Response Format - - ), + label: Advanced: Response Format, children: ( <>
    onChange({ ...value, return_raw_model_name: returnRawModelName })} + onCheckedChange={(returnRawModelName) => + onChange({ ...value, return_raw_model_name: returnRawModelName }) + } + aria-label="Return raw model name" /> - Return raw model name + Return raw model name
    - + Return the resolved underlying model name in responses instead of the autorouter alias. - + ), }, @@ -497,11 +492,7 @@ const ComplexityRouterConfig: React.FC = ({ ? [ { key: "escalation", - label: ( - - Advanced: Escalation Keywords - - ), + label: Advanced: Escalation Keywords, children: , }, ] @@ -510,11 +501,7 @@ const ComplexityRouterConfig: React.FC = ({ ? [ { key: "keyword-semantic", - label: ( - - Advanced: Keyword/Semantic Matching - - ), + label: Advanced: Keyword/Semantic Matching, children: ( <> {onKeywordTierRulesChange && ( @@ -524,9 +511,7 @@ const ComplexityRouterConfig: React.FC = ({ tierLabels={value.tier_labels} /> )} - {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && ( - - )} + {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && } {onSemanticMatchingEnabledChange && ( = ({ }, ] : []), - ]} - /> + ].map(({ key, label, children }) => ( + + + + {label} + + {children} + + ))} +
    ); }; diff --git a/ui/litellm-dashboard/src/components/add_model/EscalationKeywords.tsx b/ui/litellm-dashboard/src/components/add_model/EscalationKeywords.tsx index a7c1b7462ea..b1bb25deb13 100644 --- a/ui/litellm-dashboard/src/components/add_model/EscalationKeywords.tsx +++ b/ui/litellm-dashboard/src/components/add_model/EscalationKeywords.tsx @@ -1,10 +1,8 @@ import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Select as AntdSelect, Typography } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; import React from "react"; -const { Text } = Typography; - export const DEFAULT_ESCALATION_KEYWORDS = ["LITELLM ESCALATE"]; interface EscalationKeywordsProps { @@ -16,28 +14,24 @@ const EscalationKeywords: React.FC = ({ keywords, onCha return (
    - - Escalation Keywords - +

    Escalation Keywords

    - + Optional: when a user message contains one of these phrases, the request is bumped one tier higher than it would otherwise route to. Matching is case-sensitive, so "LITELLM ESCALATE" only fires on the exact, shouted form. Leave empty to disable. - - + ({ label: keyword, value: keyword }))} value={keywords} - onChange={onChange} + onValueChange={onChange} placeholder="e.g., LITELLM ESCALATE" - tokenSeparators={[","]} - open={false} - suffixIcon={null} - style={{ width: "100%" }} - allowClear + emptyText="Type to add a phrase" + allowCustomValues + className="w-full" />
    ); diff --git a/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx b/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx index 08d00606987..f7edd26f0d8 100644 --- a/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx +++ b/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx @@ -1,14 +1,14 @@ -import { Info, Plus, Trash2 } from "lucide-react"; +import { Inbox, Info, Plus, Trash2 } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Card, Empty, Select as AntdSelect, Typography } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import React from "react"; import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords"; import { tierOptions } from "./complexity_router_tiers"; -const { Text } = Typography; - export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING"; export interface KeywordTierRule { @@ -28,29 +28,9 @@ interface KeywordTierRulesProps { // there is no failed attempt left to surface it. const KeywordTierRules: React.FC = ({ rules, onChange, tierLabels }) => { const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules)); - const [drafts, setDrafts] = React.useState>({}); - - const setDraft = (id: string, text: string) => setDrafts((current) => ({ ...current, [id]: text })); - - // The dropdown is kept closed, which leaves antd nothing for Enter to select, so a typed keyword - // would only become a tag on blur. Submitting used to supply that blur; the button is disabled - // while the row reads as empty, so Enter has to commit the word itself or the row cannot be filled. - const commitDraft = (rule: KeywordTierRule) => { - const keyword = (drafts[rule.id] ?? "").trim(); - if (!keyword) return; - updateRule(rule.id, { keywords: [...rule.keywords, keyword] }); - setDraft(rule.id, ""); - }; - - const commitDraftOnEnter = (rule: KeywordTierRule) => (event: React.KeyboardEvent) => { - if (event.key !== "Enter") return; - event.preventDefault(); - commitDraft(rule); - }; const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => { updateRule(rule.id, { keywords }); - setDraft(rule.id, ""); }; const addRule = () => { @@ -69,9 +49,7 @@ const KeywordTierRules: React.FC = ({ rules, onChange, ti
    - - Keyword Tier Overrides - +

    Keyword Tier Overrides

    @@ -81,67 +59,71 @@ const KeywordTierRules: React.FC = ({ rules, onChange, ti Add keyword rule
    - + Optional: route requests containing specific keywords directly to a tier, e.g. route "invoice, refund, billing" to the medium tier. - + {rules.length === 0 ? ( - + +
    +
    +
    ) : (
    {rules.map((rule, index) => ( - -
    -
    - - Keywords {index + 1} - - setDraft(rule.id, text)} - onInputKeyDown={commitDraftOnEnter(rule)} - onBlur={() => commitDraft(rule)} - placeholder="e.g., invoice, refund, billing" - tokenSeparators={[","]} - open={false} - suffixIcon={null} - style={{ width: "100%" }} - allowClear - status={emptyRuleIndexes.has(index) ? "error" : undefined} - /> - {emptyRuleIndexes.has(index) && ( - - At least one keyword is required - - )} + + +
    +
    + Keywords {index + 1} + ({ label: keyword, value: keyword }))} + value={rule.keywords} + onValueChange={replaceKeywords(rule)} + placeholder="e.g., invoice, refund, billing" + emptyText="Type to add a keyword" + allowCustomValues + className={emptyRuleIndexes.has(index) ? "w-full border-destructive" : "w-full"} + /> + {emptyRuleIndexes.has(index) && ( + At least one keyword is required + )} +
    +
    + Route to tier + +
    +
    -
    - - Route to tier - - updateRule(rule.id, { tier })} - options={tierOptions(tierLabels)} - style={{ width: "100%" }} - /> -
    - -
    + ))}
    diff --git a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx index 7f8a71b1b8b..9da0bf394e5 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx @@ -1,11 +1,11 @@ import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { InputNumber, Select as AntdSelect, Switch, Typography } from "antd"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; -const { Text } = Typography; - const DEFAULT_MATCH_THRESHOLD = 0.5; interface SemanticKeywordMatchingProps { @@ -41,49 +41,49 @@ const SemanticKeywordMatching: React.FC = ({
    - Semantic keyword matching + Semantic keyword matching
    - + Uses same keyword-tier pairs as above and overrides direct keyword matching. Adds latency based on embedding model network request. - +
    - +
    {enabled && (
    - Embedding model - Embedding model + - {embeddingModelMissing && ( - - An embedding model is required - - )} + {embeddingModelMissing && An embedding model is required}
    - Minimum match score - Minimum match score + onMatchThresholdChange(value ?? DEFAULT_MATCH_THRESHOLD)} + onChange={(event) => + onMatchThresholdChange(event.target.value === "" ? DEFAULT_MATCH_THRESHOLD : event.target.valueAsNumber) + } min={0} max={1} step={0.05} - style={{ width: "100%" }} + className="w-full" /> - Match only at or above this similarity score. + Match only at or above this similarity score.
    )} diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index e3a7482b02d..e45422dee08 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -27,7 +27,7 @@ const ALL_FAMILY_MODELS: ModelGroup[] = [ const ANTHROPIC_ONLY_MODEL = ANTHROPIC_TIERS.COMPLEX[0]; const openTemplateDropdown = (): void => { - fireEvent.mouseDown(within(screen.getByTestId("template-selector")).getByRole("combobox")); + fireEvent.click(screen.getByTestId("template-selector")); }; // Detailed Configuration is collapsed by default, so any test reaching into it (a tier select, an @@ -36,15 +36,32 @@ const expandDetailedConfiguration = (): void => { fireEvent.click(screen.getByTestId("detailed-configuration-toggle")); }; -const visibleOptions = (): HTMLElement[] => - // eslint-disable-next-line local/no-antd-class-selectors -- antd puts role="option" only on a hidden mirror list of raw values; the visible options carry no role, no aria-disabled, and only a tooltip in title - Array.from(document.querySelectorAll(".ant-select-item-option")); +const visibleOptions = (): HTMLElement[] => screen.queryAllByRole("option"); const optionByLabel = (label: string): HTMLElement | undefined => visibleOptions().find((el) => el.textContent?.startsWith(label)); -// eslint-disable-next-line local/no-antd-class-selectors -- antd signals option disabled state only through this class -const isOptionDisabled = (option: HTMLElement): boolean => option.classList.contains("ant-select-item-option-disabled"); +const isOptionDisabled = (option: HTMLElement): boolean => option.getAttribute("aria-disabled") === "true"; + +const selectTemplate = async (label: string): Promise => { + await userEvent.click(optionByLabel(label)!); +}; + +// Opens the dropdown only when it is closed, since openTemplateDropdown toggles: waiting on a +// second preset in the same test would otherwise close the list out from under the poll. +const waitForPresetEnabled = async (label: string) => { + if (visibleOptions().length === 0) openTemplateDropdown(); + await waitFor(() => { + expect(isOptionDisabled(optionByLabel(label)!)).toBe(false); + }); +}; + +// The keyword field is a combobox that offers whatever is typed as a "Create ..." entry, so a +// keyword only lands on the rule once that entry is picked. +const addKeyword = async (user: ReturnType, field: HTMLElement, keyword: string) => { + await user.type(within(field).getByRole("combobox"), keyword); + await user.click(await screen.findByText(`Create "${keyword}"`)); +}; const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(() => ({ mockFetchAvailableModels: vi.fn(), @@ -202,10 +219,7 @@ describe("AddAutoRouterTab", () => { await user.click(screen.getByRole("button", { name: /add keyword rule/i })); expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled(); - await user.type( - within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"), - "invoice{enter}", - ); + await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice"); expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled(); expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument(); @@ -221,10 +235,7 @@ describe("AddAutoRouterTab", () => { expandDetailedConfiguration(); await user.click(screen.getByText("Advanced: Keyword/Semantic Matching")); await user.click(screen.getByRole("button", { name: /add keyword rule/i })); - await user.type( - within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"), - "invoice{enter}", - ); + await addKeyword(user, screen.getByText("Keywords 1").closest("div") as HTMLElement, "invoice"); await user.click(screen.getByRole("button", { name: /add keyword rule/i })); expect(await screen.findAllByText("At least one keyword is required")).toHaveLength(1); @@ -242,7 +253,7 @@ describe("AddAutoRouterTab", () => { await user.click(screen.getByText("Advanced: Keyword/Semantic Matching")); await user.click(screen.getByRole("button", { name: /add keyword rule/i })); const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement; - await user.type(within(keywordsField).getByRole("combobox"), "invoice{enter}"); + await addKeyword(user, keywordsField, "invoice"); await user.click(screen.getByRole("button", { name: /add auto router/i })); await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); @@ -407,7 +418,7 @@ describe("AddAutoRouterTab", () => { await user.click(screen.getByText("Advanced: Keyword/Semantic Matching")); await user.click(screen.getByRole("button", { name: /add keyword rule/i })); const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement; - await user.type(within(keywordsField).getByRole("combobox"), "invoice{enter}"); + await addKeyword(user, keywordsField, "invoice"); await user.click(screen.getByTestId("auto-router-test-routing-btn")); await user.type(await screen.findByTestId("auto-router-routing-test-prompt"), "reconcile this invoice"); @@ -452,17 +463,6 @@ describe("AddAutoRouterTab", () => { }); describe("template presets", () => { - // Opens the dropdown once, then waits out the useQuery load: an open antd Select re-renders its - // already-mounted options in place as state changes, so polling only re-reads the DOM here. - // Re-firing the open/close mousedown on every poll (calling openTemplateDropdown inside the - // waitFor callback) fights the dropdown's own open/close animation and hangs the test. - const waitForPresetEnabled = async (label: string) => { - openTemplateDropdown(); - await waitFor(() => { - expect(isOptionDisabled(optionByLabel(label)!)).toBe(false); - }); - }; - it("disables every preset while the model list is loading", async () => { let resolveModels: (models: ModelGroup[]) => void = () => {}; mockFetchAvailableModels.mockImplementation( @@ -537,7 +537,7 @@ describe("AddAutoRouterTab", () => { renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); expect(screen.queryByText("Advanced: Keyword/Semantic Matching")).not.toBeInTheDocument(); expect( @@ -548,11 +548,11 @@ describe("AddAutoRouterTab", () => { ).toBeInTheDocument(); }); - it("expands detailed configuration when Custom Configuration is chosen", () => { + it("expands detailed configuration when Custom Configuration is chosen", async () => { renderWithProviders(); openTemplateDropdown(); - fireEvent.click(optionByLabel("Custom Configuration")!); + await selectTemplate("Custom Configuration"); expect(screen.getByText("Advanced: Keyword/Semantic Matching")).toBeInTheDocument(); }); @@ -561,7 +561,7 @@ describe("AddAutoRouterTab", () => { mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS); renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); expect(screen.queryByText("Advanced: Keyword/Semantic Matching")).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId("detailed-configuration-toggle")); @@ -578,7 +578,7 @@ describe("AddAutoRouterTab", () => { renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router"); await user.click(screen.getByRole("button", { name: /add auto router/i })); @@ -600,7 +600,7 @@ describe("AddAutoRouterTab", () => { const { container } = renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "stale-model-router" } }); expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled(); @@ -622,26 +622,16 @@ describe("AddAutoRouterTab", () => { describe("default model pin", () => { const PINNED_MODEL = "pinned-default-model"; - const waitForPresetEnabled = async (label: string) => { - openTemplateDropdown(); - await waitFor(() => { - expect(isOptionDisabled(optionByLabel(label)!)).toBe(false); - }); - }; - const applyPresetAndPin = async (user: ReturnType) => { await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); // Applying a preset collapses Detailed Configuration, so the default model row is behind it. expandDetailedConfiguration(); const defaultModel = screen.getByRole("combobox", { name: "Default model" }); await user.click(defaultModel); - // antd virtualizes the option list and jsdom gives every row zero height, so options past - // the first window never render. Typing filters the list down to the pin instead of relying - // on its index, which adding a preset to the bundled JSON shifts. await user.type(defaultModel, PINNED_MODEL); - await user.click((await screen.findAllByTitle(PINNED_MODEL)).slice(-1)[0]); + await user.click(await screen.findByRole("option", { name: PINNED_MODEL })); }; beforeEach(() => { @@ -692,19 +682,12 @@ describe("AddAutoRouterTab", () => { mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS); }); - const waitForPresetEnabled = async (label: string) => { - openTemplateDropdown(); - await waitFor(() => { - expect(isOptionDisabled(optionByLabel(label)!)).toBe(false); - }); - }; - it("omits plan_mode_min_tier from the payload when never touched", async () => { const user = userEvent.setup(); renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); await user.type(screen.getByPlaceholderText(/smart_router/i), "no-plan-router"); await user.click(screen.getByRole("button", { name: /add auto router/i })); @@ -719,7 +702,7 @@ describe("AddAutoRouterTab", () => { renderWithProviders(); await waitForPresetEnabled("Anthropic Family"); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); expandDetailedConfiguration(); await user.click(screen.getByText("Advanced: Plan-Mode Override")); await user.click(await screen.findByRole("switch", { name: "Route plan-mode requests to a minimum tier" })); @@ -773,7 +756,7 @@ describe("AddAutoRouterTab", () => { await waitFor(() => { expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false); }); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); expect(screen.getByText("Advanced: Keyword/Semantic Matching")).toBeInTheDocument(); @@ -862,7 +845,7 @@ describe("AddAutoRouterTab", () => { await waitFor(() => { expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false); }); - fireEvent.click(optionByLabel("Anthropic Family")!); + await selectTemplate("Anthropic Family"); await user.type(screen.getByPlaceholderText(/smart_router/i), "wildcard-router"); await user.click(screen.getByRole("button", { name: /add auto router/i })); diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index a071b92fcd5..64d1519f915 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useWatch } from "react-hook-form"; -import { Card, Select as AntdSelect } from "antd"; +import { Card } from "antd"; import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react"; import { z } from "zod/v4"; import { FieldGroup } from "@/components/shared/form/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; @@ -289,6 +290,14 @@ const AddAutoRouterTab: React.FC = ({ [presetAvailability], ); + const templateItems = React.useMemo( + () => [ + ...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })), + { value: "custom", label: "Custom Configuration" }, + ], + [sortedPresetOptions], + ); + const applyPrefill = (prefill: PresetPrefill) => { setComplexityRouterConfig(prefill.complexityRouterConfig); setCustomTechnicalKeywords(prefill.customTechnicalKeywords); @@ -486,49 +495,52 @@ const AddAutoRouterTab: React.FC = ({
    - handlePresetChange(presetKey ?? undefined)} > - {sortedPresetOptions.map(({ preset, availability: presetState }) => { - const disabledHint = presetDisabledHint(presetState); - const isDisabled = disabledHint !== null; - const hintClass = isPresetHintAlarming(presetState) - ? "text-red-500 dark:text-red-400" - : "text-muted-foreground"; - const matchedHint = - presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null; + + + + + {sortedPresetOptions.map(({ preset, availability: presetState }) => { + const disabledHint = presetDisabledHint(presetState); + const hintClass = isPresetHintAlarming(presetState) + ? "text-red-500 dark:text-red-400" + : "text-muted-foreground"; + const matchedHint = + presetState.kind === "available" && presetState.viaDeployments + ? "Matches your deployments" + : null; - return ( - -
    -
    {preset.label}
    -
    {preset.description}
    - {disabledHint &&
    {disabledHint}
    } - {matchedHint && ( -
    {matchedHint}
    - )} -
    -
    - ); - })} - -
    -
    Custom Configuration
    -
    Define your auto router from scratch
    -
    -
    -
    + return ( + +
    +
    {preset.label}
    +
    {preset.description}
    + {disabledHint &&
    {disabledHint}
    } + {matchedHint && ( +
    {matchedHint}
    + )} +
    +
    + ); + })} + +
    +
    Custom Configuration
    +
    Define your auto router from scratch
    +
    +
    + + {modelsUnverifiable && (
    Could not load available models.{" "} diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index 2d35a772a63..5bc0dcd143d 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -1,14 +1,18 @@ import React from "react"; -import { Switch, Select, Tooltip, DatePicker } from "antd"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import type { Dayjs } from "dayjs"; import { ChevronDown, Info } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; -import { Row, Col, Typography } from "antd"; -import TextArea from "antd/es/input/TextArea"; +import { Textarea } from "@/components/ui/textarea"; 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 { UtcDateTimeInput } from "@/components/shared/form/UtcDateTimeInput"; import CacheControlInjectionPoints, { CACHE_CONTROL_LABEL, CACHE_CONTROL_TOOLTIP, @@ -30,7 +34,6 @@ import { PTU_END_FIELD, } from "../../utils/ptuValidation"; import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled"; -const { Link } = Typography; interface AdvancedSettingsProps { showAdvancedSettings: boolean; @@ -51,6 +54,11 @@ const USAGE_COST_FIELDS = [ const REVALIDATED_WHEN_PTU_COUNT_CHANGES = [PTU_RATE_FIELD, PTU_START_FIELD, ...USAGE_COST_FIELDS]; +const PRICING_MODEL_ITEMS = [ + { value: "per_token", label: "Per Million Tokens" }, + { value: "per_second", label: "Per Second" }, +] as const; + const validateNumber = (_: unknown, value: unknown) => { if (!value) { return Promise.resolve(); @@ -79,6 +87,14 @@ const AdvancedSettings: React.FC = ({ const [showCacheControl, setShowCacheControl] = React.useState(false); const ptuCostAttributionEnabled = usePtuCostAttributionEnabled(); + const handlePricingModelChange = + (onChange: (value: string) => void) => + (value: "per_token" | "per_second" | null): void => { + if (value === null) return; + onChange(value); + setPricingModel(value); + }; + return ( <> @@ -93,11 +109,10 @@ const AdvancedSettings: React.FC = ({ { + onCheckedChange={(checked) => { control.onChange(checked); setCustomPricing(checked); }} - className="bg-gray-600" /> )} @@ -107,7 +122,7 @@ const AdvancedSettings: React.FC = ({ label={ Attached Knowledge Bases (RAG){" "} - + = ({ > - + } className="mt-4" @@ -137,7 +152,7 @@ const AdvancedSettings: React.FC = ({ label={ Guardrails{" "} - + = ({ > - + } className="mt-4" help="Select existing guardrails. Go to 'Guardrails' tab to create new guardrails." > {(control) => ( - ({ value: tag.name, label: tag.name, - title: tag.description || tag.name, + description: tag.description || undefined, }))} + allowCustomValues /> )} @@ -249,11 +262,9 @@ const AdvancedSettings: React.FC = ({ className="mb-4" > {(control) => ( - @@ -273,11 +284,9 @@ const AdvancedSettings: React.FC = ({ className="mb-4" > {(control) => ( - @@ -291,19 +300,21 @@ const AdvancedSettings: React.FC = ({ {(control) => ( )} @@ -401,20 +412,20 @@ const AdvancedSettings: React.FC = ({ "Use in pass through routes", Allow using these credentials in pass through routes.{" "} - + Learn more - + , )} className="mb-4 mt-4" > {(control) => ( - + )} @@ -427,11 +438,10 @@ const AdvancedSettings: React.FC = ({ { + onCheckedChange={(checked) => { control.onChange(checked); setShowCacheControl(checked); }} - className="bg-gray-600" /> )} @@ -456,7 +466,7 @@ const AdvancedSettings: React.FC = ({ rules={{ validate: antdRules({ validator: formItemValidateJSON }) }} > {(control) => ( -