diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 0c1db16fc74..84a05113177 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -77,17 +77,9 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Health Status" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Add Provider" })).toBeInTheDocument(); expect(screen.getByTestId("panel-all-models")).toBeInTheDocument(); }); - it("hides the write-only Add Provider tab from a view-only admin", () => { - mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); - renderPage(); - expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument(); - }); - it("switches tabs in-memory, mounting only the active panel", async () => { const user = userEvent.setup(); renderPage(); @@ -114,7 +106,6 @@ describe("ModelsAndEndpointsPage", () => { renderPage(); expect(screen.queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument(); }); // POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 0af494c0ce1..34c9d87004e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -17,7 +17,6 @@ import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/us import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel"; import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel"; import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel"; -import AddProviderPanel from "@/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel"; import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel"; import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel"; import HealthStatusPanel from "@/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel"; @@ -30,7 +29,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; type ModelTabSlug = | "add" - | "add-provider" | "auto-routers" | "llm-credentials" | "pass-through" @@ -44,7 +42,6 @@ const BASE_TAB_KEY = "all-models"; const TAB_LABELS: Record = { add: "Add Model", - "add-provider": "Add Provider", "auto-routers": "Auto-Routers", "llm-credentials": "LLM Credentials", "pass-through": "Pass-Through Endpoints", @@ -63,8 +60,6 @@ const renderPanel = (key: string) => { return ; case "add": return ; - case "add-provider": - return ; case "llm-credentials": return ; case "pass-through": @@ -111,9 +106,6 @@ export default function ModelsAndEndpointsPage() { "", ...(canCreate ? (["add"] as const) : []), ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), - // effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show - // a viewer this write-only wizard; only the raw-role isViewOnly separates them. - ...(isAdmin && !isViewOnly ? (["add-provider"] as const) : []), ...(isAdmin ? ([ "llm-credentials", @@ -126,7 +118,7 @@ export default function ModelsAndEndpointsPage() { ] as const) : []), ], - [canCreate, isAdmin, isViewOnly], + [canCreate, isAdmin], ); const allModelsLabel = isAdmin ? "All Models" : "Your Models"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx index c1ddc666cbd..6516ac06f8f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.integration.test.tsx @@ -128,9 +128,10 @@ const PROXY_ADMIN = { accessToken: "test-access-token" }; const setup = async () => { const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - renderWithProviders(); + const onClose = vi.fn(); + renderWithProviders(); await screen.findByLabelText("Provider"); - return { user }; + return { user, onClose }; }; const chooseProvider = async (user: ReturnType, name: string) => { @@ -193,7 +194,7 @@ describe("AddProviderPanel", () => { it("walks provider -> credential -> discover -> review -> create, with blocked and aliases wired correctly", async () => { discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus", "claude-3-haiku"] }); - const { user } = await setup(); + const { user, onClose } = await setup(); await chooseProvider(user, "Anthropic"); await user.type(screen.getByLabelText("Credential name"), "anthropic-prod"); @@ -259,6 +260,10 @@ describe("AddProviderPanel", () => { expect(await screen.findByText(/claude-3-opus: created/)).toBeInTheDocument(); expect(screen.getByText(/claude-3-haiku: created/)).toBeInTheDocument(); expect(screen.getByText(/claude-hidden: created/)).toBeInTheDocument(); + + expect(onClose).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledTimes(1); }); it("shows a sanitized discovery error with a working retry", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx index eb87398a0ff..7b6908539f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel.tsx @@ -94,7 +94,11 @@ const StepIndicator: React.FC<{ step: WizardStep; skipJwks: boolean }> = ({ step ); }; -export default function AddProviderPanel() { +interface AddProviderPanelProps { + onClose: () => void; +} + +export default function AddProviderPanel({ onClose }: AddProviderPanelProps) { const { accessToken } = useAuthorized(); const queryClient = useQueryClient(); const { data: providerMetadata } = useProviderFields(); @@ -314,8 +318,7 @@ export default function AddProviderPanel() { const isInternalIssuer = savedValues.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR; return ( -
-

Add Provider

+
{step === "provider" && ( @@ -400,6 +403,7 @@ export default function AddProviderPanel() { isDone={step === "done"} creationResults={creationResults} aliasCollisions={aliasCollisions} + onClose={onClose} /> )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx index 21faaf29115..ed5bcc923aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/add-provider/WizardSteps.tsx @@ -188,9 +188,16 @@ interface ResultsStepProps { isDone: boolean; creationResults: CreationResult[]; aliasCollisions: string[]; + onClose: () => void; } -export const ResultsStep: React.FC = ({ isCreating, isDone, creationResults, aliasCollisions }) => ( +export const ResultsStep: React.FC = ({ + isCreating, + isDone, + creationResults, + aliasCollisions, + onClose, +}) => ( {isCreating && ( @@ -219,6 +226,9 @@ export const ResultsStep: React.FC = ({ isCreating, isDone, cr )} +
+ +
)}
diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx index 92885262dca..67774efc1e9 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -1,10 +1,9 @@ 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 { CredentialItem, credentialCreateCall, credentialUpdateCall } from "@/components/networking"; -import { toast } from "@/lib/toast"; +import { CredentialItem, credentialUpdateCall } from "@/components/networking"; import CredentialsPanel from "./CredentialsPanel"; @@ -23,13 +22,12 @@ vi.mock("@/components/networking", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - credentialCreateCall: vi.fn(), credentialUpdateCall: vi.fn(), credentialDeleteCall: vi.fn(), }; }); -// Stub the modal so the panel's submit handlers can be driven directly: the +// Stub the edit modal so the panel's submit handler can be driven directly: the // button fires onSubmit with form-shaped values, and it only renders when open. vi.mock("./CredentialModal", () => ({ default: function CredentialModalMock({ @@ -44,15 +42,12 @@ vi.mock("./CredentialModal", () => ({ if (!open) { return null; } - const values = - mode === "edit" - ? { - credential_name: "openai-key", - custom_llm_provider: "openai", - api_key: "sk-1****2345", - api_base: "https://proxy.e2e.example.com/v1", - } - : { credential_name: "new-cred", custom_llm_provider: "openai" }; + const values = { + credential_name: "openai-key", + custom_llm_provider: "openai", + api_key: "sk-1****2345", + api_base: "https://proxy.e2e.example.com/v1", + }; return ( + ); + }, +})); + const credentials: CredentialItem[] = [ { credential_name: "openai-key", @@ -128,54 +133,33 @@ describe("CredentialsPanel", () => { expect(screen.queryByText("No credentials configured")).not.toBeInTheDocument(); }); - it("opens the add modal when the add button is clicked", async () => { + it("opens the Add Credential wizard, not the credential form, when the add button is clicked", async () => { const user = userEvent.setup(); mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); renderPanel(); - expect(screen.queryByTestId("credential-modal-add-submit")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /add credential/i })); - expect(screen.getByTestId("credential-modal-add-submit")).toBeInTheDocument(); - }); - - it("closes the add modal and refetches after a successful add", async () => { - const user = userEvent.setup(); - const refetch = vi.fn(); - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); - mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch }); - vi.mocked(credentialCreateCall).mockResolvedValueOnce(undefined as never); - - renderPanel(); - - await user.click(screen.getByRole("button", { name: /add credential/i })); - await user.click(screen.getByTestId("credential-modal-add-submit")); - - await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith("Credential added successfully"); - }); - expect(refetch).toHaveBeenCalled(); + const dialog = await screen.findByRole("dialog", { name: "Add Credential" }); + expect(within(dialog).getByTestId("add-credential-wizard-close")).toBeInTheDocument(); expect(screen.queryByTestId("credential-modal-add-submit")).not.toBeInTheDocument(); }); - it("surfaces an error and keeps the add modal open when the create call fails", async () => { + it("closes the wizard dialog when the wizard finishes", async () => { const user = userEvent.setup(); mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); - vi.mocked(credentialCreateCall).mockRejectedValueOnce(new Error("network down")); renderPanel(); await user.click(screen.getByRole("button", { name: /add credential/i })); - await user.click(screen.getByTestId("credential-modal-add-submit")); + await user.click(await screen.findByTestId("add-credential-wizard-close")); await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith("Failed to add credential"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - // The modal stays open so the user can retry, and no success toast fired. - expect(screen.getByTestId("credential-modal-add-submit")).toBeInTheDocument(); - expect(toast.success).not.toHaveBeenCalled(); }); it("drops the masked api key from the update payload while keeping the edited api base", async () => { diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx index cc9e94772ab..f15fc62b725 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -5,13 +5,10 @@ import { useState } from "react"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { - credentialCreateCall, - credentialDeleteCall, - CredentialItem, - credentialUpdateCall, -} from "@/components/networking"; +import AddProviderPanel from "@/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel"; +import { credentialDeleteCall, CredentialItem, credentialUpdateCall } from "@/components/networking"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { stripMaskedSecrets } from "@/utils/maskedSecretUtils"; import { isProxyAdminRole } from "@/utils/roles"; @@ -40,7 +37,7 @@ export default function CredentialsPanel() { const { data: credentialsResponse, isLoading, refetch: refetchCredentials } = useCredentials(); const credentialList = credentialsResponse?.credentials || []; - const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isAddWizardOpen, setIsAddWizardOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); const [selectedCredential, setSelectedCredential] = useState(null); const [credentialToDelete, setCredentialToDelete] = useState(null); @@ -65,21 +62,6 @@ export default function CredentialsPanel() { } }; - const handleAddCredential = async (values: Record) => { - if (!accessToken) { - return; - } - try { - const newCredential = buildCredential(values, withoutRestrictedFields(values)); - await credentialCreateCall(accessToken, newCredential); - toast.success("Credential added successfully"); - setIsAddModalOpen(false); - await refetchCredentials(); - } catch (error) { - toast.error("Failed to add credential"); - } - }; - const handleDeleteCredential = async () => { if (!accessToken || !credentialToDelete) { return; @@ -120,7 +102,7 @@ export default function CredentialsPanel() { Configured credentials for different AI providers. Add and manage your API credentials.

{canModifyCredentials && ( - @@ -135,13 +117,15 @@ export default function CredentialsPanel() { isLoading={isLoading} /> - {isAddModalOpen && ( - setIsAddModalOpen(false)} - /> + {isAddWizardOpen && ( + !open && setIsAddWizardOpen(false)}> + + + Add Credential + + setIsAddWizardOpen(false)} /> + + )} {isUpdateModalOpen && (