diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 4b9f7d217dc..7158c452d94 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1,59 +1,36 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; +import NotificationsManager from "./molecules/notifications_manager"; +import * as networking from "./networking"; vi.mock("../../utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true), })); +vi.mock("./molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + fromBackend: vi.fn(), + }, +})); + vi.mock("./networking", () => ({ - modelInfoV1Call: vi.fn().mockResolvedValue({ - data: [ - { - model_name: "GPT-4", - litellm_params: { - model: "gpt-4", - api_base: "https://api.openai.com/v1", - custom_llm_provider: "openai", - }, - model_info: { - id: "123", - created_by: "123", - db_model: true, - input_cost_per_token: 0.00003, - output_cost_per_token: 0.00006, - }, - }, - ], - }), - credentialGetCall: vi.fn().mockResolvedValue({ - credential_name: "test-credential", - credential_values: {}, - credential_info: {}, - }), - getGuardrailsList: vi.fn().mockResolvedValue({ - guardrails: [{ guardrail_name: "content_filter" }, { guardrail_name: "toxicity_filter" }], - }), - tagListCall: vi.fn().mockResolvedValue({ - test_tag: { - name: "test_tag", - description: "A test tag", - }, - production_tag: { - name: "production_tag", - description: "Production ready models", - }, - }), - testConnectionRequest: vi.fn().mockResolvedValue({ - status: "success", - }), - modelPatchUpdateCall: vi.fn().mockResolvedValue({}), - modelDeleteCall: vi.fn().mockResolvedValue({}), + modelInfoV1Call: vi.fn(), + credentialGetCall: vi.fn(), + getGuardrailsList: vi.fn(), + tagListCall: vi.fn(), + testConnectionRequest: vi.fn(), + modelPatchUpdateCall: vi.fn(), + modelDeleteCall: vi.fn(), + credentialCreateCall: vi.fn(), })); -// Mock the useModelsInfo hook since it uses React Query const mockUseModelsInfo = vi.fn(); const mockUseModelHub = vi.fn(); @@ -62,12 +39,21 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelHub: (...args: any[]) => mockUseModelHub(...args), })); -// Mock the useModelCostMap hook const mockUseModelCostMap = vi.fn(); vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: (...args: any[]) => mockUseModelCostMap(...args), })); +const mockNotificationsManager = vi.mocked(NotificationsManager); +const mockModelInfoV1Call = vi.mocked(networking.modelInfoV1Call); +const mockCredentialGetCall = vi.mocked(networking.credentialGetCall); +const mockGetGuardrailsList = vi.mocked(networking.getGuardrailsList); +const mockTagListCall = vi.mocked(networking.tagListCall); +const mockTestConnectionRequest = vi.mocked(networking.testConnectionRequest); +const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall); +const mockModelDeleteCall = vi.mocked(networking.modelDeleteCall); +const mockCredentialCreateCall = vi.mocked(networking.credentialCreateCall); + describe("ModelInfoView", () => { let queryClient: QueryClient; @@ -88,6 +74,16 @@ describe("ModelInfoView", () => { }, }; + const DEFAULT_ADMIN_PROPS = { + modelId: "123", + onClose: vi.fn(), + accessToken: "test-token", + userID: "123", + userRole: "Admin", + onModelUpdate: vi.fn(), + modelAccessGroups: ["group1", "group2"], + }; + beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { @@ -98,7 +94,6 @@ describe("ModelInfoView", () => { }); vi.clearAllMocks(); - // Set up default mocks mockUseModelsInfo.mockReturnValue({ data: { data: [defaultModelData], @@ -120,89 +115,170 @@ describe("ModelInfoView", () => { isLoading: false, error: null, }); + + mockModelInfoV1Call.mockResolvedValue({ + data: [defaultModelData], + }); + + mockCredentialGetCall.mockResolvedValue({ + credential_name: "test-credential", + credential_values: {}, + credential_info: {}, + }); + + mockGetGuardrailsList.mockResolvedValue({ + guardrails: [{ guardrail_name: "content_filter" }, { guardrail_name: "toxicity_filter" }], + }); + + mockTagListCall.mockResolvedValue({ + test_tag: { + name: "test_tag", + description: "A test tag", + models: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + production_tag: { + name: "production_tag", + description: "Production ready models", + models: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }); + + mockTestConnectionRequest.mockResolvedValue({ + status: "success", + }); + + mockModelPatchUpdateCall.mockResolvedValue({}); + mockModelDeleteCall.mockResolvedValue({}); + mockCredentialCreateCall.mockResolvedValue({}); }); const wrapper = ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); - const DEFAULT_ADMIN_PROPS = { - modelId: "123", - onClose: () => {}, - accessToken: "123", - userID: "123", - userRole: "Admin", - onModelUpdate: () => {}, - modelAccessGroups: [], - }; + it("should render", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText("Model Settings")).toBeInTheDocument(); + }); + }); - describe("Edit Model", () => { - it("should render the model info view", async () => { - const { getByText } = render(, { wrapper }); - await waitFor(() => { - expect(getByText("Model Settings")).toBeInTheDocument(); - }); + it("should display loading state when model data is loading", () => { + mockUseModelsInfo.mockReturnValue({ + data: null, + isLoading: true, + error: null, }); - it("should not render an edit settings button if the model is not a DB model", async () => { - const nonDbModelData = { - ...defaultModelData, - model_info: { - ...defaultModelData.model_info, - db_model: false, - }, - }; + render(, { wrapper }); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); - mockUseModelsInfo.mockReturnValue({ - data: { - data: [nonDbModelData], - }, - isLoading: false, - error: null, - }); + it("should display not found message when model data is not available", async () => { + mockUseModelsInfo.mockReturnValue({ + data: { + data: [], + }, + isLoading: false, + error: null, + }); - const { queryByText } = render(, { wrapper }); - await waitFor(() => { - expect(queryByText("Edit Settings")).not.toBeInTheDocument(); - }); + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText("Model not found")).toBeInTheDocument(); }); + }); - it("should render tags in the edit model", async () => { - const { getByText } = render(, { wrapper }); - await waitFor(() => { - expect(getByText("Tags")).toBeInTheDocument(); - }); + it("should display model name in the header", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText(/Public Model Name:/)).toBeInTheDocument(); + }); + }); + + it("should display back button that calls onClose when clicked", async () => { + const mockOnClose = vi.fn(); + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByText("Model Settings")).toBeInTheDocument(); + }); + + const backButton = screen.getByRole("button", { name: /back to models/i }); + await user.click(backButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should display test connection button", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /test connection/i })).toBeInTheDocument(); + }); + }); + + it("should test connection when test connection button is clicked", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByText("Model Settings")).toBeInTheDocument(); + }); + + const testButton = screen.getByRole("button", { name: /test connection/i }); + await user.click(testButton); + + await waitFor(() => { + expect(mockTestConnectionRequest).toHaveBeenCalled(); + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Connection test successful!"); + }); + }); + + it("should display error notification when connection test fails", async () => { + const user = userEvent.setup(); + mockTestConnectionRequest.mockRejectedValue(new Error("Connection failed")); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByText("Model Settings")).toBeInTheDocument(); }); - it("should render the litellm params in the edit model", async () => { - const { getByText } = render(, { wrapper }); - await waitFor(() => { - expect(getByText("LiteLLM Params")).toBeInTheDocument(); - }); + const testButton = screen.getByRole("button", { name: /test connection/i }); + await user.click(testButton); + + await waitFor(() => { + expect(mockNotificationsManager.error).toHaveBeenCalled(); }); }); - it("should render a test connection button", async () => { - const { getByTestId } = render(, { wrapper }); + it("should display reuse credentials button for admin users", async () => { + render(, { wrapper }); await waitFor(() => { - expect(getByTestId("test-connection-button")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /re-use credentials/i })).toBeInTheDocument(); }); }); - it("should render a reuse credentials button", async () => { - const { getByTestId } = render(, { wrapper }); + it("should disable reuse credentials button for non-admin users", async () => { + render(, { wrapper }); await waitFor(() => { - expect(getByTestId("reuse-credentials-button")).toBeInTheDocument(); + const button = screen.getByRole("button", { name: /re-use credentials/i }); + expect(button).toBeDisabled(); }); }); - it("should render a delete model button", async () => { - const { getByTestId } = render(, { wrapper }); + it("should display delete model button", async () => { + render(, { wrapper }); await waitFor(() => { - expect(getByTestId("delete-model-button")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete model/i })).toBeInTheDocument(); }); }); - it("should render a disabled delete model button if the model is not a DB model", async () => { + it("should disable delete button when model is not a DB model", async () => { const nonDbModelData = { ...defaultModelData, model_info: { @@ -219,13 +295,14 @@ describe("ModelInfoView", () => { error: null, }); - const { getByTestId } = render(, { wrapper }); + render(, { wrapper }); await waitFor(() => { - expect(getByTestId("delete-model-button")).toBeDisabled(); + const deleteButton = screen.getByRole("button", { name: /delete model/i }); + expect(deleteButton).toBeDisabled(); }); }); - it("should render a disabled delete model button if the user is not an admin and model is not created by the user", async () => { + it("should disable delete button when user is not admin and did not create the model", async () => { const nonCreatedByUserModelData = { ...defaultModelData, model_info: { @@ -242,18 +319,177 @@ describe("ModelInfoView", () => { error: null, }); - const NON_CREATED_BY_USER_ADMIN_PROPS = { - ...DEFAULT_ADMIN_PROPS, - userRole: "User", + render(, { wrapper }); + await waitFor(() => { + const deleteButton = screen.getByRole("button", { name: /delete model/i }); + expect(deleteButton).toBeDisabled(); + }); + }); + + it("should display overview and raw JSON tabs", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /overview/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /raw json/i })).toBeInTheDocument(); + }); + }); + + it("should display model information in overview tab", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText("Provider")).toBeInTheDocument(); + expect(screen.getByText("LiteLLM Model")).toBeInTheDocument(); + expect(screen.getByText("Pricing")).toBeInTheDocument(); + }); + }); + + it("should display edit settings button when user can edit model", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + }); + + it("should not display edit settings button when model is not a DB model", async () => { + const nonDbModelData = { + ...defaultModelData, + model_info: { + ...defaultModelData.model_info, + db_model: false, + }, }; - const { getByTestId } = render(, { wrapper }); + mockUseModelsInfo.mockReturnValue({ + data: { + data: [nonDbModelData], + }, + isLoading: false, + error: null, + }); + + render(, { wrapper }); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /edit settings/i })).not.toBeInTheDocument(); + }); + }); + + it("should enter edit mode when edit settings button is clicked", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + }); + + it("should display form fields in edit mode", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Enter model name")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter LiteLLM model name")).toBeInTheDocument(); + }); + }); + + it("should allow editing model name in edit mode", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + const modelNameInput = await screen.findByPlaceholderText("Enter model name"); + await user.clear(modelNameInput); + await user.type(modelNameInput, "Updated Model Name"); + + expect(modelNameInput).toHaveValue("Updated Model Name"); + }); + + it("should cancel editing when cancel button is clicked", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); + }); + }); + + it("should save model changes when save button is clicked", async () => { + const user = userEvent.setup(); + const mockOnModelUpdate = vi.fn(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: /save changes/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Model settings updated successfully"); + expect(mockOnModelUpdate).toHaveBeenCalled(); + }); + }); + + it("should display tags section", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + }); + + it("should display LiteLLM Params section", async () => { + render(, { wrapper }); await waitFor(() => { - expect(getByTestId("delete-model-button")).toBeDisabled(); + expect(screen.getByText("LiteLLM Params")).toBeInTheDocument(); }); }); - it("should render health check model field for wildcard routes", async () => { + it("should display health check model field for wildcard models", async () => { const wildcardModelData = { ...defaultModelData, litellm_params: { @@ -270,38 +506,71 @@ describe("ModelInfoView", () => { error: null, }); - const { getByText } = render(, { wrapper }); + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText("Health Check Model")).toBeInTheDocument(); + }); + }); + + it("should not display health check model field for non-wildcard models", async () => { + render(, { wrapper }); await waitFor(() => { - expect(getByText("Model Settings")).toBeInTheDocument(); + expect(screen.getByText("Model Settings")).toBeInTheDocument(); + expect(screen.queryByText("Health Check Model")).not.toBeInTheDocument(); + }); + }); + + it("should display edit auto router button for auto router models", async () => { + const autoRouterModelData = { + ...defaultModelData, + litellm_params: { + ...defaultModelData.litellm_params, + auto_router_config: {}, + }, + }; + + mockUseModelsInfo.mockReturnValue({ + data: { + data: [autoRouterModelData], + }, + isLoading: false, + error: null, }); + + render(, { wrapper }); await waitFor(() => { - expect(getByText("Health Check Model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /edit auto router/i })).toBeInTheDocument(); }); }); - it("should not render health check model field for non-wildcard routes", async () => { - const { queryByText } = render(, { wrapper }); + + it("should display model access groups field", async () => { + render(, { wrapper }); await waitFor(() => { - expect(queryByText("Model Settings")).toBeInTheDocument(); + expect(screen.getByText("Model Access Groups")).toBeInTheDocument(); }); + }); + + it("should display guardrails field", async () => { + render(, { wrapper }); await waitFor(() => { - expect(queryByText("Health Check Model")).not.toBeInTheDocument(); + expect(screen.getByText("Guardrails")).toBeInTheDocument(); }); }); - describe("View Model", () => { - it("should render the model info view", async () => { - const { getByText } = render(, { wrapper }); - await waitFor(() => { - expect(getByText("Model Settings")).toBeInTheDocument(); - }); + it("should display pricing information", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText(/Input:/)).toBeInTheDocument(); + expect(screen.getByText(/Output:/)).toBeInTheDocument(); }); + }); - it("should render tags in the view model", async () => { - const { getByText } = render(, { wrapper }); - await waitFor(() => { - expect(getByText("Tags")).toBeInTheDocument(); - }); + it("should display created at and created by information", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByText(/Created At/)).toBeInTheDocument(); + expect(screen.getByText(/Created By/)).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index a55149124c9..e2fc8caa21c 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -136,11 +136,9 @@ export default function ModelInfoView({ useEffect(() => { const getExistingCredential = async () => { - console.log("accessToken, ", accessToken); if (!accessToken) return; if (usingExistingCredential) return; let existingCredentialResponse = await credentialGetCall(accessToken, null, modelId); - console.log("existingCredentialResponse, ", existingCredentialResponse); setExistingCredential({ credential_name: existingCredentialResponse["credential_name"], credential_values: existingCredentialResponse["credential_values"], @@ -153,7 +151,6 @@ export default function ModelInfoView({ // Only fetch if we don't have modelData yet if (modelData) return; let modelInfoResponse = await modelInfoV1Call(accessToken, modelId); - console.log("modelInfoResponse, ", modelInfoResponse); let specificModelData = modelInfoResponse.data[0]; if (specificModelData && !specificModelData.litellm_model_name) { specificModelData = { @@ -201,7 +198,6 @@ export default function ModelInfoView({ }, [accessToken, modelId]); const handleReuseCredential = async (values: any) => { - console.log("values, ", values); if (!accessToken) return; let credentialItem = { credential_name: values.credential_name, @@ -212,7 +208,6 @@ export default function ModelInfoView({ }; NotificationsManager.info("Storing credential.."); let credentialResponse = await credentialCreateCall(accessToken, credentialItem); - console.log("credentialResponse, ", credentialResponse); NotificationsManager.success("Credential stored successfully"); }; @@ -221,8 +216,6 @@ export default function ModelInfoView({ if (!accessToken) return; setIsSaving(true); - console.log("values.model_name, ", values.model_name); - // Parse LiteLLM extra params from JSON text area let parsedExtraParams: Record = {}; try { @@ -412,7 +405,6 @@ export default function ModelInfoView({ } }; const isWildcardModel = modelData.litellm_model_name.includes("*"); - console.log("isWildcardModel, ", isWildcardModel); return (
@@ -657,7 +649,7 @@ export default function ModelInfoView({ ? (localModelData.litellm_params?.input_cost_per_token * 1_000_000).toFixed(4) : localModelData?.model_info?.input_cost_per_token ? (localModelData.model_info.input_cost_per_token * 1_000_000).toFixed(4) - : null} + : "Not Set"}
)} @@ -674,7 +666,7 @@ export default function ModelInfoView({ ? (localModelData.litellm_params.output_cost_per_token * 1_000_000).toFixed(4) : localModelData?.model_info?.output_cost_per_token ? (localModelData.model_info.output_cost_per_token * 1_000_000).toFixed(4) - : null} + : "Not Set"} )}