diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx new file mode 100644 index 0000000000..aee7a0cdd1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import AddCredentialModal from "./AddCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +describe("AddCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Add New Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should show the correct provider fields", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx new file mode 100644 index 0000000000..694a98201c --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx @@ -0,0 +1,118 @@ +import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import React, { useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; + +interface AddCredentialsModalProps { + open: boolean; + onCancel: () => void; + onAddCredential: (values: any) => void; + uploadProps: UploadProps; +} + +const AddCredentialsModal: React.FC = ({ open, onCancel, onAddCredential, uploadProps }) => { + const [form] = Form.useForm(); + const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); + + const handleSubmit = (values: any) => { + const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { + if (value !== "" && value !== undefined && value !== null) { + acc[key] = value; + } + return acc; + }, {} as any); + onAddCredential(filteredValues); + form.resetFields(); + }; + + return ( + { + onCancel(); + form.resetFields(); + }} + footer={null} + width={600} + > +
+ {/* Credential Name */} + + + + + {/* Provider Selection */} + + { + setSelectedProvider(value as Providers); + form.setFieldValue("custom_llm_provider", value); + }} + > + {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( + +
+ {`${providerEnum} { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + const fallbackDiv = document.createElement("div"); + fallbackDiv.className = + "w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"; + fallbackDiv.textContent = providerDisplayName.charAt(0); + parent.replaceChild(fallbackDiv, target); + } + }} + /> + {providerDisplayName} +
+
+ ))} +
+
+ + + + {/* Modal Footer */} +
+ + Need Help? + + +
+ + +
+
+ +
+ ); +}; + +export default AddCredentialsModal; diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx new file mode 100644 index 0000000000..def3b4f6cd --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import { CredentialItem } from "../networking"; +import EditCredentialModal from "./EditCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +const mockCredential: CredentialItem = { + credential_name: "test-credential", + credential_values: { + api_key: "test-api-key", + api_base: "https://api.test.com", + }, + credential_info: { + custom_llm_provider: Providers.OpenAI, + }, +}; + +describe("EditCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Edit Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should render initial values", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement; + expect(credentialNameInput.value).toBe("test-credential"); + expect(credentialNameInput.disabled).toBe(true); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx similarity index 78% rename from ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx rename to ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx index 9c061eb121..b206ed6c91 100644 --- a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx @@ -1,34 +1,29 @@ -import React, { useEffect, useState } from "react"; -import { Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd"; -import type { UploadProps } from "antd/es/upload"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; -import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import { useEffect, useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { CredentialItem } from "../networking"; -const { Title, Link } = Typography; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; -interface AddCredentialsModalProps { - isVisible: boolean; +interface EditCredentialsModalProps { + open: boolean; onCancel: () => void; - onAddCredential: (values: any) => void; onUpdateCredential: (values: any) => void; uploadProps: UploadProps; - addOrEdit: "add" | "edit"; existingCredential: CredentialItem | null; } -const AddCredentialsModal: React.FC = ({ - isVisible, +export default function EditCredentialsModal({ + open, onCancel, - onAddCredential, onUpdateCredential, uploadProps, - addOrEdit, existingCredential, -}) => { +}: EditCredentialsModalProps) { const [form] = Form.useForm(); - const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); const handleSubmit = (values: any) => { const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { @@ -37,23 +32,25 @@ const AddCredentialsModal: React.FC = ({ } return acc; }, {} as any); - if (addOrEdit === "add") { - onAddCredential(filteredValues); - } else { - onUpdateCredential(filteredValues); - } + onUpdateCredential(filteredValues); form.resetFields(); }; useEffect(() => { if (existingCredential) { + // Spread all credential_values dynamically, converting undefined/null to null for form compatibility + const credentialValues = Object.entries(existingCredential.credential_values || {}).reduce( + (acc, [key, value]) => { + acc[key] = value ?? null; + return acc; + }, + {} as Record, + ); + form.setFieldsValue({ credential_name: existingCredential.credential_name, custom_llm_provider: existingCredential.credential_info.custom_llm_provider, - api_base: existingCredential.credential_values.api_base, - api_version: existingCredential.credential_values.api_version, - base_model: existingCredential.credential_values.base_model, - api_key: existingCredential.credential_values.api_key, + ...credentialValues, }); setSelectedProvider(existingCredential.credential_info.custom_llm_provider as Providers); } @@ -61,14 +58,15 @@ const AddCredentialsModal: React.FC = ({ return ( { onCancel(); form.resetFields(); }} footer={null} width={600} + destroyOnHidden={true} >
{/* Credential Name */} @@ -142,12 +140,10 @@ const AddCredentialsModal: React.FC = ({ > Cancel - +
); -}; - -export default AddCredentialsModal; +} diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index e36a759294..eecd26db25 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -19,10 +19,11 @@ import { credentialUpdateCall, CredentialItem, } from "@/components/networking"; // Assume this is your networking function -import AddCredentialsTab from "./add_credentials_tab"; +import AddCredentialsTab from "./AddCredentialModal"; import CredentialDeleteModal from "./CredentialDeleteModal"; import { Form } from "antd"; import NotificationsManager from "../molecules/notifications_manager"; +import EditCredentialsModal from "./EditCredentialModal"; interface CredentialsPanelProps { accessToken: string | null; uploadProps: UploadProps; @@ -60,10 +61,10 @@ const CredentialsPanel: React.FC = ({ }, }; - const response = await credentialUpdateCall(accessToken, values.credential_name, newCredential); + await credentialUpdateCall(accessToken, values.credential_name, newCredential); NotificationsManager.success("Credential updated successfully"); setIsUpdateModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; const handleAddCredential = async (values: any) => { @@ -84,10 +85,10 @@ const CredentialsPanel: React.FC = ({ }; // Add to list and close modal - const response = await credentialCreateCall(accessToken, newCredential); + await credentialCreateCall(accessToken, newCredential); NotificationsManager.success("Credential added successfully"); setIsAddModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; useEffect(() => { @@ -189,23 +190,18 @@ const CredentialsPanel: React.FC = ({ {isAddModalOpen && ( setIsAddModalOpen(false)} uploadProps={uploadProps} - addOrEdit="add" - onUpdateCredential={handleUpdateCredential} - existingCredential={null} /> )} {isUpdateModalOpen && ( - setIsUpdateModalOpen(false)} - addOrEdit="edit" /> )}