From 9028e69e76503d3b870b3a38efcb0af231808b1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 12:28:03 +0000 Subject: [PATCH] feat(ui): migrate standalone modals to shadcn Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 1 + ui/litellm-dashboard/docs/BLOCKERS.md | 10 + .../src/components/SSOModals.test.tsx | 555 +++++++------ .../src/components/SSOModals.tsx | 726 +++++++++++------- .../src/components/cloudzero_export_modal.tsx | 306 ++++---- .../edit_auto_router_modal.tsx | 331 +++++--- .../organisms/RegenerateKeyModal.tsx | 258 +++++-- .../src/components/survey/SurveyModal.tsx | 93 +-- 8 files changed, 1309 insertions(+), 971 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index cad0840a673..5e6965516fa 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -109,6 +109,7 @@ "src/components/permissions/AgentPermissions.tsx", "src/components/permissions/MCPServerPermissions.tsx", "src/components/survey/ClaudeCodeModal.tsx", + "src/components/survey/SurveyModal.tsx", "src/components/view_logs/ToolsSection/ToolItem.tsx", "src/components/guardrails/content_filter/PatternTable.tsx", "src/components/guardrails/content_filter/ContentFilterDisplay.tsx", diff --git a/ui/litellm-dashboard/docs/BLOCKERS.md b/ui/litellm-dashboard/docs/BLOCKERS.md index 498377d08c7..bf418564c7e 100644 --- a/ui/litellm-dashboard/docs/BLOCKERS.md +++ b/ui/litellm-dashboard/docs/BLOCKERS.md @@ -31,6 +31,16 @@ stay on antd for phase 1 and will be addressed in a targeted follow-up. with Switch/InputNumber/Select controls. Defer until shared section unblocks. - `src/components/add_pass_through.tsx`: same dependency chain. +- `src/components/OldTeams.tsx` (1577 LoC): legacy teams page that mirrors + `team/TeamInfo.tsx` in structure — deeply coupled antd `Form` with model / + guardrail multi-selects, `Form.List`-style panels, antd `Pagination` + `Table` + driven by server-side sort, antd `Tabs` container, and Tremor `Accordion` + sections for router / logging / advanced settings. Same migration surface + and blockers as TeamInfo (still-required Tremor `Accordion`, wide antd Form + context consumed by several common_components). Defer until the TeamInfo + blocker clears — these two pages must be migrated together or the shared + form-surface common_components (ModelAliasManager, PremiumLoggingSettings, + RouterSettingsAccordion) will be in an inconsistent state. - `src/components/team/TeamInfo.tsx` (1724 LoC): team detail/edit page with a deeply coupled antd `Form` — `Form.List` + inline Promise validators for per-model rate limits, `Form.useWatch` hooks driving dependent rendering, diff --git a/ui/litellm-dashboard/src/components/SSOModals.test.tsx b/ui/litellm-dashboard/src/components/SSOModals.test.tsx index 365d23f4036..0e40d3f233d 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.test.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.test.tsx @@ -1,15 +1,12 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { Form } from "antd"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import SSOModals from "./SSOModals"; -// Mock the networking functions vi.mock("./networking", () => ({ getSSOSettings: vi.fn(), updateSSOSettings: vi.fn(), })); -// Mock parseErrorMessage vi.mock("./shared/errorUtils", () => ({ parseErrorMessage: vi.fn((error) => error?.message || "An error occurred"), })); @@ -17,227 +14,204 @@ vi.mock("./shared/errorUtils", () => ({ import NotificationsManager from "./molecules/notifications_manager"; import { getSSOSettings, updateSSOSettings } from "./networking"; +interface ExternalFormHandle { + resetFields: () => void; + setFieldsValue: (values: Record) => void; + getFieldsValue: () => Record; +} + +function makeExternalForm(): ExternalFormHandle { + return { + resetFields: () => {}, + setFieldsValue: () => {}, + getFieldsValue: () => ({}), + }; +} + +/** shadcn `Select` renders a hidden native ` + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + + )} + /> + ); + })} + + ); +} + +function RoleMappingFields() { + const { control, register } = useFormContext(); + return ( + <> + ( +
+ +
+ + {fieldState.error && ( +

{fieldState.error.message}

+ )} +
+
+ )} + /> + ( +
+ + +
+ )} + /> + {[ + { name: "proxy_admin_teams", label: "Proxy Admin Teams" }, + { name: "admin_viewer_teams", label: "Admin Viewer Teams" }, + { name: "internal_user_teams", label: "Internal User Teams" }, + { name: "internal_viewer_teams", label: "Internal Viewer Teams" }, + ].map((f) => ( +
+ + +
+ ))} + + ); +} + const SSOModals: React.FC = ({ isAddSSOModalVisible, isInstructionsModalVisible, @@ -110,31 +277,48 @@ const SSOModals: React.FC = ({ handleShowInstructions, handleInstructionsOk, handleInstructionsCancel, - form, + form: externalForm, accessToken, - ssoConfigured = false, // Default to false if not provided + ssoConfigured = false, }) => { const [isClearConfirmModalVisible, setIsClearConfirmModalVisible] = useState(false); + const rhfForm = useForm({ + defaultValues: defaultFormValues, + mode: "onSubmit", + }); + + // Bridge the optional antd-style `form` prop to the internal RHF form, so + // existing callers that still do `Form.useForm()` and call `resetFields` / + // `setFieldsValue` on the instance keep working during the migration. + useEffect(() => { + if (!externalForm) return; + const patchedResetFields = () => rhfForm.reset(defaultFormValues); + const patchedSetFieldsValue = (values: Partial) => { + Object.entries(values).forEach(([k, v]) => { + rhfForm.setValue(k as any, v as any, { shouldDirty: false }); + }); + }; + const patchedGetFieldsValue = () => rhfForm.getValues(); + externalForm.resetFields = patchedResetFields; + externalForm.setFieldsValue = patchedSetFieldsValue; + externalForm.getFieldsValue = patchedGetFieldsValue; + }, [externalForm, rhfForm]); + + const provider = rhfForm.watch("sso_provider"); + const useRoleMappings = rhfForm.watch("use_role_mappings"); - // Load existing SSO settings when modal opens useEffect(() => { const loadSSOSettings = async () => { if (isAddSSOModalVisible && accessToken) { try { const ssoData = await getSSOSettings(accessToken); - console.log("Raw SSO data received:", ssoData); // Debug log if (ssoData && ssoData.values) { - console.log("SSO values:", ssoData.values); // Debug log - console.log("user_email from API:", ssoData.values.user_email); // Debug log - - // Determine which SSO provider is configured - let selectedProvider = null; + let selectedProvider: string = ""; if (ssoData.values.google_client_id) { selectedProvider = "google"; } else if (ssoData.values.microsoft_client_id) { selectedProvider = "microsoft"; } else if (ssoData.values.generic_client_id) { - // Check if it looks like Okta based on endpoints if ( ssoData.values.generic_authorization_endpoint?.includes("okta") || ssoData.values.generic_authorization_endpoint?.includes("auth0") @@ -145,12 +329,10 @@ const SSOModals: React.FC = ({ } } - // Extract role mappings if they exist - let roleMappingFields = {}; + let roleMappingFields: Partial = {}; if (ssoData.values.role_mappings) { const roleMappings = ssoData.values.role_mappings; - // Helper function to join arrays into comma-separated strings const joinTeams = (teams: string[] | undefined): string => { if (!teams || teams.length === 0) return ""; return teams.join(", "); @@ -167,23 +349,19 @@ const SSOModals: React.FC = ({ }; } - // Set form values with existing data (excluding UI access control fields) const formValues = { + ...defaultFormValues, sso_provider: selectedProvider, - proxy_base_url: ssoData.values.proxy_base_url, - user_email: ssoData.values.user_email, + proxy_base_url: ssoData.values.proxy_base_url ?? "", + user_email: ssoData.values.user_email ?? "", ...ssoData.values, ...roleMappingFields, }; - console.log("Setting form values:", formValues); // Debug log - - // Clear form first, then set values with a small delay to ensure proper initialization - form.resetFields(); - setTimeout(() => { - form.setFieldsValue(formValues); - console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log - }, 100); + rhfForm.reset({ + ...defaultFormValues, + ...formValues, + }); } } catch (error) { console.error("Failed to load SSO settings:", error); @@ -192,10 +370,9 @@ const SSOModals: React.FC = ({ }; loadSSOSettings(); - }, [isAddSSOModalVisible, accessToken, form]); + }, [isAddSSOModalVisible, accessToken, rhfForm]); - // Enhanced form submission handler - const handleFormSubmit = async (formValues: Record) => { + const onSubmit = rhfForm.handleSubmit(async (formValues) => { if (!accessToken) { NotificationsManager.fromBackend("No access token available"); return; @@ -213,13 +390,27 @@ const SSOModals: React.FC = ({ ...rest } = formValues; - const payload: any = { - ...rest, + // Strip fields that belong to providers other than the selected one, and + // drop any empty strings so we don't send blank fields to the backend. + const selectedConfig = ssoProviderConfigs[rest.sso_provider]; + const allowedProviderKeys = new Set( + selectedConfig ? selectedConfig.fields.map((f) => f.name) : [], + ); + const payload: Record = { + sso_provider: rest.sso_provider, + user_email: rest.user_email, + proxy_base_url: rest.proxy_base_url, }; + for (const key of providerFieldKeys) { + if (allowedProviderKeys.has(key)) { + const v = (rest as any)[key]; + if (v !== undefined && v !== null && v !== "") { + payload[key] = v; + } + } + } - // Add role mappings if use_role_mappings is checked if (use_role_mappings) { - // Helper function to split comma-separated string into array const splitTeams = (teams: string | undefined): string[] => { if (!teams || teams.trim() === "") return []; return teams @@ -228,7 +419,6 @@ const SSOModals: React.FC = ({ .filter((team) => team.length > 0); }; - // Map default role display values to backend values const defaultRoleMapping: Record = { internal_user_viewer: "internal_user_viewer", internal_user: "internal_user", @@ -249,17 +439,14 @@ const SSOModals: React.FC = ({ }; } - // Save SSO settings using the new API await updateSSOSettings(accessToken, payload); - // Continue with the original flow (show instructions) handleShowInstructions(formValues); } catch (error: unknown) { NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error)); } - }; + }); - // Handle clearing SSO settings const handleClearSSO = async () => { if (!accessToken) { NotificationsManager.fromBackend("No access token available"); @@ -267,7 +454,6 @@ const SSOModals: React.FC = ({ } try { - // Clear all SSO settings const clearSettings = { google_client_id: null, google_client_secret: null, @@ -287,13 +473,10 @@ const SSOModals: React.FC = ({ await updateSSOSettings(accessToken, clearSettings); - // Clear the form - form.resetFields(); + rhfForm.reset(defaultFormValues); - // Close the confirmation modal setIsClearConfirmModalVisible(false); - // Close the main SSO modal and trigger refresh handleAddSSOOk(); NotificationsManager.success("SSO settings cleared successfully"); @@ -303,258 +486,219 @@ const SSOModals: React.FC = ({ } }; - // Helper function to render provider fields - const renderProviderFields = (provider: string) => { - const config = ssoProviderConfigs[provider]; - if (!config) return null; - - return config.fields.map((field) => ( - - {field.name.includes("client") ? : } - - )); - }; - return ( <> - -
- <> - - - - - prevValues.sso_provider !== currentValues.sso_provider} - > - {({ getFieldValue }) => { - const provider = getFieldValue("sso_provider"); - return provider ? renderProviderFields(provider) : null; - }} - - - - - - value?.trim()} - rules={[ - { required: true, message: "Please enter the proxy base url" }, - { - pattern: /^https?:\/\/.+/, - message: "URL must start with http:// or https://", - }, - { - validator: (_, value) => { - // Only check for trailing slash if the URL starts with http:// or https:// - if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) { - return Promise.reject("URL must not end with a trailing slash"); - } - return Promise.resolve(); - }, - }, - ]} - > - - - - prevValues.sso_provider !== currentValues.sso_provider} - > - {({ getFieldValue }) => { - const provider = getFieldValue("sso_provider"); - return provider === "okta" || provider === "generic" ? ( - - - - ) : null; - }} - - - - prevValues.use_role_mappings !== currentValues.use_role_mappings - } - > - {({ getFieldValue }) => { - const useRoleMappings = getFieldValue("use_role_mappings"); - return useRoleMappings ? ( - - - - ) : null; - }} - - - - prevValues.use_role_mappings !== currentValues.use_role_mappings - } - > - {({ getFieldValue }) => { - const useRoleMappings = getFieldValue("use_role_mappings"); - return useRoleMappings ? ( - <> - - - - - - - - - - - - - - - - - - - - - ) : null; - }} - - -
- {ssoConfigured && ( - setIsClearConfirmModalVisible(true)} - style={{ - backgroundColor: "#6366f1", - borderColor: "#6366f1", - color: "white", - }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = "#5558eb"; - e.currentTarget.style.borderColor = "#5558eb"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "#6366f1"; - e.currentTarget.style.borderColor = "#6366f1"; - }} - > - Clear - - )} - Save -
-
-
- - {/* Clear Confirmation Modal */} - setIsClearConfirmModalVisible(false)} - okText="Yes, Clear" - cancelText="Cancel" - okButtonProps={{ - danger: true, - style: { - backgroundColor: "#dc2626", - borderColor: "#dc2626", - }, + onOpenChange={(o) => { + if (!o) handleAddSSOCancel(); }} > -

Are you sure you want to clear all SSO settings? This action cannot be undone.

-

Users will no longer be able to login using SSO after this change.

-
+ + + {ssoConfigured ? "Edit SSO Settings" : "Add SSO"} + + +
+ ( +
+ +
+ + {fieldState.error && ( +

{fieldState.error.message}

+ )} +
+
+ )} + /> - : null} + + ( +
+ +
+ + {fieldState.error && ( +

{fieldState.error.message}

+ )} +
+
+ )} + /> + + { + const value = (rawValue ?? "").trim(); + if (!URL_PATTERN.test(value)) { + return "URL must start with http:// or https://"; + } + if (value.endsWith("/")) { + return "URL must not end with a trailing slash"; + } + return true; + }, + }} + render={({ field, fieldState }) => ( +
+ +
+ field.onChange(e.target.value)} + onBlur={(e) => { + field.onChange(e.target.value.trim()); + field.onBlur(); + }} + /> + {fieldState.error && ( +

{fieldState.error.message}

+ )} +
+
+ )} + /> + + {(provider === "okta" || provider === "generic") && ( + ( +
+ + field.onChange(c === true)} + aria-label="Use Role Mappings" + /> +
+ )} + /> + )} + + {useRoleMappings ? : null} + +
+ {ssoConfigured && ( + + )} + +
+ +
+
+ + + { + if (!o) setIsClearConfirmModalVisible(false); + }} > -

Follow these steps to complete the SSO setup:

- 1. DO NOT Exit this TAB - 2. Open a new tab, visit your proxy base url - 3. Confirm your SSO is configured correctly and you can login on the new Tab - 4. If Step 3 is successful, you can close this tab -
- Done -
- + + + Confirm Clear SSO Settings + +
+

Are you sure you want to clear all SSO settings? This action cannot be undone.

+

Users will no longer be able to login using SSO after this change.

+
+ + + + +
+
+ + { + if (!o) handleInstructionsCancel(); + }} + > + + + SSO Setup Instructions + +
+

Follow these steps to complete the SSO setup:

+

1. DO NOT Exit this TAB

+

2. Open a new tab, visit your proxy base url

+

3. Confirm your SSO is configured correctly and you can login on the new Tab

+

4. If Step 3 is successful, you can close this tab

+
+ + + +
+
); }; -export { ssoProviderConfigs }; // Export for use in other components +export { ssoProviderConfigs }; export default SSOModals; diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx index 63a221f7dfc..37bd8f67278 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx @@ -1,7 +1,23 @@ import React, { useState, useEffect } from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Text, Button, Callout, TextInput } from "@tremor/react"; -import { Modal, Form, Spin, Select } from "antd"; +import { FileText, CheckCircle2, Plus } from "lucide-react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { getGlobalLitellmHeaderName } from "@/components/networking"; import NotificationsManager from "./molecules/notifications_manager"; @@ -25,18 +41,20 @@ interface CloudZeroSettingsView { type ExportType = "cloudzero" | "csv"; const CloudZeroExportModal: React.FC = ({ isOpen, onClose, accessToken }) => { - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { api_key: "", connection_id: "" }, + }); const [loading, setLoading] = useState(false); const [existingSettings, setExistingSettings] = useState(null); const [settingsLoading, setSettingsLoading] = useState(false); const [exportType, setExportType] = useState("cloudzero"); const [exportLoading, setExportLoading] = useState(false); - // Load existing settings when modal opens useEffect(() => { if (isOpen && accessToken) { loadExistingSettings(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, accessToken]); const loadExistingSettings = async () => { @@ -53,12 +71,11 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC if (response.ok) { const settings = await response.json(); setExistingSettings(settings); - // Pre-populate form with existing settings (except masked API key) - form.setFieldsValue({ + form.reset({ + api_key: "", connection_id: settings.connection_id, }); } else if (response.status !== 404) { - // 404 means no settings configured yet, which is fine const errorData = await response.json(); NotificationsManager.fromBackend(`Failed to load existing settings: ${errorData.error || "Unknown error"}`); } @@ -81,7 +98,6 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC const endpoint = existingSettings ? "/cloudzero/settings" : "/cloudzero/init"; const method = existingSettings ? "PUT" : "POST"; - // Add default timezone for backend compatibility const payload = { ...values, timezone: "UTC", @@ -158,7 +174,6 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC const handleExportCSV = async () => { setExportLoading(true); try { - // TODO: Implement CSV export functionality NotificationsManager.info("CSV export functionality coming soon!"); onClose(); } catch (error) { @@ -171,9 +186,10 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC const handleExport = async () => { if (exportType === "cloudzero") { - // Check if settings exist, if not save them first if (!existingSettings) { - const values = await form.validateFields(); + const ok = await form.trigger(); + if (!ok) return; + const values = form.getValues(); const success = await handleSaveCloudZeroSettings(values); if (!success) return; } @@ -183,141 +199,157 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC } }; - const handleModalClose = () => { - form.resetFields(); + const handleModalClose = (open: boolean) => { + if (open) return; + form.reset({ api_key: "", connection_id: "" }); setExportType("cloudzero"); setExistingSettings(null); onClose(); }; - const exportOptions = [ - { - value: "cloudzero", - label: ( -
- CloudZero { - // Fallback to text if image fails to load - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - Export to CloudZero -
- ), - }, - { - value: "csv", - label: ( -
- - - - Export to CSV -
- ), - }, - ]; - return ( - -
- {/* Export Type Selection */} -
- Export Destination - setExportType(v as ExportType)}> + + + + + +
+ CloudZero { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + Export to CloudZero +
+
+ +
+ + Export to CSV +
+
+
+
- )} - {/* CSV Export Info */} - {exportType === "csv" && ( - ( - - - - )} - color="blue" - > - Export your usage data as a CSV file for analysis in spreadsheet applications. - - )} + {/* CloudZero Configuration */} + {exportType === "cloudzero" && ( +
+ {settingsLoading ? ( +
+ +
+ ) : ( + <> + {existingSettings && ( +
+ +
+

Existing CloudZero Configuration

+

+ API Key: {existingSettings.api_key_masked} +
+ Connection ID: {existingSettings.connection_id} +

+
+
+ )} - {/* Action Buttons */} -
- - + {!existingSettings && ( + +
+
+ + ( + <> + + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + )} + /> +
+ +
+ + ( + <> + + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + )} + /> +
+
+
+ )} + + )} +
+ )} + + {/* CSV Export Info */} + {exportType === "csv" && ( +
+ +
+

CSV Export

+

+ Export your usage data as a CSV file for analysis in spreadsheet applications. +

+
+
+ )} + + {/* Action Buttons */} +
+ + +
-
-
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index ff5086c225c..6ebbcd837c0 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -8,7 +10,15 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Form, Select as AntdSelect } from "antd"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, @@ -28,6 +38,96 @@ interface EditAutoRouterModalProps { userRole: string; } +interface FormValues { + auto_router_name: string; + auto_router_default_model: string; + auto_router_embedding_model: string; + model_access_group: string[]; +} + +function TagsInput({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: string[]; + placeholder?: string; +}) { + const [query, setQuery] = useState(""); + const selected = value ?? []; + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o)), + [options, selected], + ); + + const addTag = (tag: string) => { + const trimmed = tag.trim(); + if (!trimmed) return; + if (selected.includes(trimmed)) return; + onChange([...selected, trimmed]); + setQuery(""); + }; + + return ( +
+
+ setQuery(e.target.value)} + placeholder={placeholder} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(query); + } else if (e.key === "Backspace" && !query && selected.length) { + onChange(selected.slice(0, -1)); + } + }} + /> +
+ {remaining.length > 0 && ( + + )} + {selected.length > 0 && ( +
+ {selected.map((v) => ( + + {v} + + + ))} +
+ )} +
+ ); +} + const EditAutoRouterModal: React.FC = ({ isVisible, onCancel, @@ -36,14 +136,17 @@ const EditAutoRouterModal: React.FC = ({ accessToken, userRole, }) => { - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { + auto_router_name: "", + auto_router_default_model: "", + auto_router_embedding_model: "", + model_access_group: [], + }, + }); const [loading, setLoading] = useState(false); const [modelAccessGroups, setModelAccessGroups] = useState([]); const [modelInfo, setModelInfo] = useState([]); - const [_showCustomDefaultModel, setShowCustomDefaultModel] = - useState(false); - const [_showCustomEmbeddingModel, setShowCustomEmbeddingModel] = - useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [routerConfig, setRouterConfig] = useState(null); @@ -62,7 +165,7 @@ const EditAutoRouterModal: React.FC = ({ setRouterConfig(parsedConfig); - form.setFieldsValue({ + form.reset({ auto_router_name: modelData.model_name, auto_router_default_model: modelData.litellm_params?.auto_router_default_model || "", @@ -70,23 +173,11 @@ const EditAutoRouterModal: React.FC = ({ modelData.litellm_params?.auto_router_embedding_model || "", model_access_group: modelData.model_info?.access_groups || [], }); - - const allModelGroups = new Set( - modelInfo.map((model) => model.model_group), - ); - setShowCustomDefaultModel( - !allModelGroups.has(modelData.litellm_params?.auto_router_default_model), - ); - setShowCustomEmbeddingModel( - !allModelGroups.has( - modelData.litellm_params?.auto_router_embedding_model, - ), - ); } catch (error) { console.error("Error parsing auto router config:", error); NotificationsManager.fromBackend("Error loading auto router configuration"); } - }, [form, modelData, modelInfo]); + }, [form, modelData]); useEffect(() => { if (isVisible && modelData) { @@ -132,12 +223,10 @@ const EditAutoRouterModal: React.FC = ({ } }, [isVisible, accessToken]); - const handleSubmit = async () => { + const handleSubmit = form.handleSubmit(async (values) => { try { setLoading(true); - const values = await form.validateFields(); - // Prepare the updated litellm_params const updatedLitellmParams = { ...modelData.litellm_params, auto_router_config: JSON.stringify(routerConfig), @@ -145,7 +234,6 @@ const EditAutoRouterModal: React.FC = ({ auto_router_embedding_model: values.auto_router_embedding_model || undefined, }; - // Prepare updated model_info const updatedModelInfo = { ...modelData.model_info, access_groups: values.model_access_group || [], @@ -175,12 +263,9 @@ const EditAutoRouterModal: React.FC = ({ } finally { setLoading(false); } - }; + }); - const modelOptions = modelInfo.map((model) => ({ - value: model.model_group, - label: model.model_group, - })); + const modelOptions = modelInfo.map((model) => model.model_group); return ( = ({ models, and access settings.

-
- - - - -
- { - setRouterConfig(config); - }} - /> -
- - - { - setShowCustomDefaultModel(value === "custom"); - }} - options={[ - ...modelOptions, - { value: "custom", label: "Enter custom model name" }, - ]} - showSearch={true} - /> - - - - { - setShowCustomEmbeddingModel(value === "custom"); - }} - options={[ - ...modelOptions, - { value: "custom", label: "Enter custom model name" }, - ]} - showSearch={true} - allowClear - /> - - - {userRole === "Admin" && ( - - ({ - value: group, - label: group, - }))} - maxTagCount="responsive" - allowClear + + +
+ + - - )} - + {form.formState.errors.auto_router_name && ( +

+ {form.formState.errors.auto_router_name.message as string} +

+ )} +
+ +
+ { + setRouterConfig(config); + }} + /> +
+ +
+ + ( + <> + + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + )} + /> +
+ +
+ + ( + + )} + /> +
+ + {userRole === "Admin" && ( +
+ +

+ Control who can access this auto router +

+ ( + + )} + /> +
+ )} + +
{option.id === "other" && isSelected && ( @@ -309,7 +292,7 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) { Leave your email and we may reach out to learn more about your experience. This is completely optional.

{/* Progress Bar */} - + {/* Content */}
{renderStepContent()}
@@ -362,15 +345,14 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
{step > 1 && ( - )}
); } -