From 371089cc838143d8fd648d9e3617e215f6e76d3a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:44:50 -0700 Subject: [PATCH] feat(ui): open the provider wizard from Add Credential instead of an Add Provider tab The wizard now lives in a dialog behind the Add Credential button on the LLM Credentials tab, so the credential list and the flow that creates credentials sit in one place. The wizard gets an onClose prop and a Close button on its results step. The add-only mode of the credential form is no longer reachable from the panel --- .../models-and-endpoints/page.test.tsx | 9 --- .../(dashboard)/models-and-endpoints/page.tsx | 10 +-- .../AddProviderPanel.integration.test.tsx | 11 ++- .../panels/add-provider/AddProviderPanel.tsx | 10 ++- .../panels/add-provider/WizardSteps.tsx | 12 +++- .../model_add/CredentialsPanel.test.tsx | 68 +++++++------------ .../components/model_add/CredentialsPanel.tsx | 44 ++++-------- 7 files changed, 67 insertions(+), 97 deletions(-) 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 && (