mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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
This commit is contained in:
parent
4dfec1dcb0
commit
371089cc83
7 changed files with 67 additions and 97 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<ModelTabSlug, string> = {
|
||||
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 <AutoRoutersTabPanel />;
|
||||
case "add":
|
||||
return <AddModelPanel />;
|
||||
case "add-provider":
|
||||
return <AddProviderPanel />;
|
||||
case "llm-credentials":
|
||||
return <LlmCredentialsPanel />;
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -128,9 +128,10 @@ const PROXY_ADMIN = { accessToken: "test-access-token" };
|
|||
|
||||
const setup = async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<AddProviderPanel />);
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<AddProviderPanel onClose={onClose} />);
|
||||
await screen.findByLabelText("Provider");
|
||||
return { user };
|
||||
return { user, onClose };
|
||||
};
|
||||
|
||||
const chooseProvider = async (user: ReturnType<typeof userEvent.setup>, 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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="mb-4 text-2xl font-semibold text-foreground">Add Provider</h2>
|
||||
<div>
|
||||
<StepIndicator step={step} skipJwks={!isInternalIssuer} />
|
||||
|
||||
{step === "provider" && (
|
||||
|
|
@ -400,6 +403,7 @@ export default function AddProviderPanel() {
|
|||
isDone={step === "done"}
|
||||
creationResults={creationResults}
|
||||
aliasCollisions={aliasCollisions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -188,9 +188,16 @@ interface ResultsStepProps {
|
|||
isDone: boolean;
|
||||
creationResults: CreationResult[];
|
||||
aliasCollisions: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ResultsStep: React.FC<ResultsStepProps> = ({ isCreating, isDone, creationResults, aliasCollisions }) => (
|
||||
export const ResultsStep: React.FC<ResultsStepProps> = ({
|
||||
isCreating,
|
||||
isDone,
|
||||
creationResults,
|
||||
aliasCollisions,
|
||||
onClose,
|
||||
}) => (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
{isCreating && (
|
||||
|
|
@ -219,6 +226,9 @@ export const ResultsStep: React.FC<ResultsStepProps> = ({ isCreating, isDone, cr
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/components/networking")>();
|
||||
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 (
|
||||
<button data-testid={`credential-modal-${mode}-submit`} onClick={() => onSubmit(values)}>
|
||||
submit {mode}
|
||||
|
|
@ -61,6 +56,16 @@ vi.mock("./CredentialModal", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel", () => ({
|
||||
default: function AddProviderPanelMock({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<button data-testid="add-credential-wizard-close" onClick={onClose}>
|
||||
close wizard
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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<CredentialItem | null>(null);
|
||||
const [credentialToDelete, setCredentialToDelete] = useState<CredentialItem | null>(null);
|
||||
|
|
@ -65,21 +62,6 @@ export default function CredentialsPanel() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = async (values: Record<string, unknown>) => {
|
||||
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.
|
||||
</p>
|
||||
{canModifyCredentials && (
|
||||
<Button onClick={() => setIsAddModalOpen(true)}>
|
||||
<Button onClick={() => setIsAddWizardOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Credential
|
||||
</Button>
|
||||
|
|
@ -135,13 +117,15 @@ export default function CredentialsPanel() {
|
|||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{isAddModalOpen && (
|
||||
<CredentialModal
|
||||
mode="add"
|
||||
onSubmit={handleAddCredential}
|
||||
open={isAddModalOpen}
|
||||
onCancel={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
{isAddWizardOpen && (
|
||||
<Dialog open onOpenChange={(open) => !open && setIsAddWizardOpen(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Credential</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddProviderPanel onClose={() => setIsAddWizardOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{isUpdateModalOpen && (
|
||||
<CredentialModal
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue