From 39ae7549a4e7a482b56015f4060cc3f31a634870 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 10 Nov 2025 11:10:06 -0800 Subject: [PATCH] [Feature] UI - Model Info Page Health Check (#16416) * Add test connection button to model info page * Unused import --- .../src/components/chat_ui/ChatUI.tsx | 2 +- .../src/components/chat_ui/chatUtils.test.ts | 14 -- .../src/components/chat_ui/chatUtils.ts | 3 - .../src/components/model_info_view.test.tsx | 157 +++++++++--------- .../src/components/model_info_view.tsx | 123 +++++++++----- .../src/utils/textUtils.test.ts | 14 +- ui/litellm-dashboard/src/utils/textUtils.ts | 4 + 7 files changed, 182 insertions(+), 135 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/chat_ui/chatUtils.test.ts delete mode 100644 ui/litellm-dashboard/src/components/chat_ui/chatUtils.ts diff --git a/ui/litellm-dashboard/src/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/chat_ui/ChatUI.tsx index 809c7493eb..a006d6734b 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/ChatUI.tsx @@ -33,7 +33,7 @@ import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants"; import ChatImageRenderer from "./ChatImageRenderer"; import ChatImageUpload from "./ChatImageUpload"; import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils"; -import { truncateString } from "./chatUtils"; +import { truncateString } from "../../utils/textUtils"; import { generateCodeSnippet } from "./CodeSnippets"; import EndpointSelector from "./EndpointSelector"; import { makeAnthropicMessagesRequest } from "./llm_calls/anthropic_messages"; diff --git a/ui/litellm-dashboard/src/components/chat_ui/chatUtils.test.ts b/ui/litellm-dashboard/src/components/chat_ui/chatUtils.test.ts deleted file mode 100644 index 547bc72e81..0000000000 --- a/ui/litellm-dashboard/src/components/chat_ui/chatUtils.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { truncateString } from "./chatUtils"; - -describe("chatUtils", () => { - describe("truncateString", () => { - it("should truncate a string", () => { - expect(truncateString("Hello, world!", 5)).toBe("Hello..."); - }); - - it("should return the original string if it is less than the max length", () => { - expect(truncateString("Hello, world!", 20)).toBe("Hello, world!"); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/chat_ui/chatUtils.ts b/ui/litellm-dashboard/src/components/chat_ui/chatUtils.ts deleted file mode 100644 index bd590a5ae1..0000000000 --- a/ui/litellm-dashboard/src/components/chat_ui/chatUtils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function truncateString(str: string, maxLength: number) { - return str.length > maxLength ? str.substring(0, maxLength) + "..." : str; -} 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 99b6d90998..1e2803b812 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -127,23 +127,23 @@ describe("ModelInfoView", () => { supported_openai_params: ["temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty"], }; + const DEFAULT_ADMIN_PROPS = { + modelId: "123", + onClose: () => {}, + modelData: modelData, + accessToken: "123", + userID: "123", + userRole: "Admin", + editModel: false, + setEditModalVisible: () => {}, + setSelectedModel: () => {}, + onModelUpdate: () => {}, + modelAccessGroups: [], + }; + describe("Edit Model", () => { it("should render the model info view", async () => { - const { getByText } = render( - {}} - modelData={modelData} - accessToken="123" - userID="123" - userRole="Admin" - editModel={false} - setEditModalVisible={() => {}} - setSelectedModel={() => {}} - onModelUpdate={() => {}} - modelAccessGroups={[]} - />, - ); + const { getByText } = render(); await waitFor(() => { expect(getByText("Model Settings")).toBeInTheDocument(); }); @@ -158,86 +158,93 @@ describe("ModelInfoView", () => { }, }; - const { queryByText } = render( - {}} - modelData={nonDbModelData} - accessToken="123" - userID="123" - userRole="Admin" - editModel={false} - setEditModalVisible={() => {}} - setSelectedModel={() => {}} - onModelUpdate={() => {}} - modelAccessGroups={[]} - />, - ); + const NON_DB_ADMIN_PROPS = { + ...DEFAULT_ADMIN_PROPS, + modelData: nonDbModelData, + }; + + const { queryByText } = render(); await waitFor(() => { expect(queryByText("Edit Model")).not.toBeInTheDocument(); }); }); it("should render tags in the edit model", async () => { - const { getByText } = render( - {}} - modelData={modelData} - accessToken="123" - userID="123" - userRole="Admin" - editModel={true} - setEditModalVisible={() => {}} - setSelectedModel={() => {}} - onModelUpdate={() => {}} - modelAccessGroups={[]} - />, - ); + const { getByText } = render(); await waitFor(() => { expect(getByText("Tags")).toBeInTheDocument(); }); }); }); + it("should render a test connection button", async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId("test-connection-button")).toBeInTheDocument(); + }); + }); + + it("should render a reuse credentials button", async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId("reuse-credentials-button")).toBeInTheDocument(); + }); + }); + + it("should render a delete model button", async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId("delete-model-button")).toBeInTheDocument(); + }); + }); + + it("should render a disabled delete model button if the model is not a DB model", async () => { + const nonDbModelData = { + ...modelData, + model_info: { + ...modelData.model_info, + db_model: false, + }, + }; + const NON_DB_ADMIN_PROPS = { + ...DEFAULT_ADMIN_PROPS, + modelData: nonDbModelData, + }; + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId("delete-model-button")).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 () => { + const nonCreatedByUserModelData = { + ...modelData, + model_info: { + ...modelData.model_info, + created_by: "456", + }, + }; + const NON_CREATED_BY_USER_ADMIN_PROPS = { + ...DEFAULT_ADMIN_PROPS, + modelData: nonCreatedByUserModelData, + userRole: "User", + }; + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId("delete-model-button")).toBeDisabled(); + }); + }); + describe("View Model", () => { it("should render the model info view", async () => { - const { getByText } = render( - {}} - modelData={modelData} - accessToken="123" - userID="123" - userRole="Admin" - editModel={false} - setEditModalVisible={() => {}} - setSelectedModel={() => {}} - onModelUpdate={() => {}} - modelAccessGroups={[]} - />, - ); + const { getByText } = render(); await waitFor(() => { expect(getByText("Model Settings")).toBeInTheDocument(); }); }); it("should render tags in the view model", async () => { - const { getByText } = render( - {}} - modelData={modelData} - accessToken="123" - userID="123" - userRole="Admin" - editModel={false} - setEditModalVisible={() => {}} - setSelectedModel={() => {}} - onModelUpdate={() => {}} - modelAccessGroups={[]} - />, - ); + const { getByText } = render(); await waitFor(() => { expect(getByText("Tags")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index df5292b52e..d9212d72d0 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -1,40 +1,42 @@ -import React, { useState, useEffect } from "react"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { ArrowLeftIcon, KeyIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline"; import { Card, - Title, - Text, + Grid, Tab, - TabList, TabGroup, + TabList, TabPanel, TabPanels, - Grid, - Button as TremorButton, + Text, TextInput, + Title, + Button as TremorButton, } from "@tremor/react"; -import NumericalInput from "./shared/numerical_input"; -import { ArrowLeftIcon, TrashIcon, KeyIcon } from "@heroicons/react/outline"; +import { Button, Form, Input, Modal, Select, Tooltip } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils"; +import { truncateString } from "../utils/textUtils"; +import CacheControlSettings from "./add_model/cache_control_settings"; +import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; +import ReuseCredentialsModal from "./model_add/reuse_credentials"; +import NotificationsManager from "./molecules/notifications_manager"; import { - modelDeleteCall, CredentialItem, - credentialGetCall, credentialCreateCall, + credentialGetCall, + getGuardrailsList, + modelDeleteCall, modelInfoV1Call, modelPatchUpdateCall, - getGuardrailsList, tagListCall, + testConnectionRequest, } from "./networking"; -import { Button, Form, Input, Select, Modal, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; import { getProviderLogoAndName } from "./provider_info_helpers"; -import { getDisplayModelName } from "./view_model/model_name_display"; -import ReuseCredentialsModal from "./model_add/reuse_credentials"; -import CacheControlSettings from "./add_model/cache_control_settings"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils"; -import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal"; -import NotificationsManager from "./molecules/notifications_manager"; +import NumericalInput from "./shared/numerical_input"; import { Tag } from "./tag_management/types"; +import { getDisplayModelName } from "./view_model/model_name_display"; interface ModelInfoViewProps { modelId: string; @@ -262,6 +264,37 @@ export default function ModelInfoView({ ); } + const handleTestConnection = async () => { + if (!accessToken) return; + try { + NotificationsManager.info("Testing connection..."); + const response = await testConnectionRequest( + accessToken, + { + custom_llm_provider: localModelData.litellm_params.custom_llm_provider, + litellm_credential_name: localModelData.litellm_params.litellm_credential_name, + model: localModelData.litellm_model_name, + }, + { + mode: localModelData.model_info?.mode, + }, + localModelData.model_info?.mode, + ); + + if (response.status === "success") { + NotificationsManager.success("Connection test successful!"); + } else { + throw new Error(response?.result?.error || response?.message || "Unknown error"); + } + } catch (error) { + if (error instanceof Error) { + NotificationsManager.error("Error testing connection: " + truncateString(error.message, 100)); + } else { + NotificationsManager.error("Error testing connection: " + String(error)); + } + } + }; + const handleDelete = async () => { try { if (!accessToken) return; @@ -323,26 +356,36 @@ export default function ModelInfoView({
- {isAdmin && ( - setIsCredentialModalOpen(true)} - className="flex items-center" - > - Re-use Credentials - - )} - {canEditModel && ( - setIsDeleteModalOpen(true)} - className="flex items-center" - > - Delete Model - - )} + + Test Connection + + + setIsCredentialModalOpen(true)} + className="flex items-center" + disabled={!isAdmin} + data-testid="reuse-credentials-button" + > + Re-use Credentials + + setIsDeleteModalOpen(true)} + className="flex items-center text-red-500 border-red-500" + disabled={!canEditModel} + data-testid="delete-model-button" + > + Delete Model +
diff --git a/ui/litellm-dashboard/src/utils/textUtils.test.ts b/ui/litellm-dashboard/src/utils/textUtils.test.ts index 2a036104b5..c67c6bf4e8 100644 --- a/ui/litellm-dashboard/src/utils/textUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/textUtils.test.ts @@ -1,8 +1,18 @@ import { describe, it, expect } from "vitest"; -import { formatLabel } from "./textUtils"; +import { formatLabel, truncateString } from "./textUtils"; -describe("textUtils", () => { +describe("formatLabel", () => { it("should format label", () => { expect(formatLabel("test_label")).toBe("Test Label"); }); }); + +describe("truncateString", () => { + it("should truncate a string", () => { + expect(truncateString("Hello, world!", 5)).toBe("Hello..."); + }); + + it("should return the original string if it is less than the max length", () => { + expect(truncateString("Hello, world!", 20)).toBe("Hello, world!"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/textUtils.ts b/ui/litellm-dashboard/src/utils/textUtils.ts index 07c4583d3f..6dd94ef137 100644 --- a/ui/litellm-dashboard/src/utils/textUtils.ts +++ b/ui/litellm-dashboard/src/utils/textUtils.ts @@ -6,3 +6,7 @@ export const formatLabel = (text: string): string => { const withSpaces = text.replace(/_/g, " "); return withSpaces.replace(/\b\w/g, (char) => char.toUpperCase()); }; + +export function truncateString(str: string, maxLength: number) { + return str.length > maxLength ? str.substring(0, maxLength) + "..." : str; +}