diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx deleted file mode 100644 index ce6dd0879fa..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/* @vitest-environment jsdom */ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import ModelsAndEndpointsView from "./ModelsAndEndpointsView"; - -// Mock localStorage -const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value; - }, - removeItem: (key: string) => { - delete store[key]; - }, - clear: () => { - store = {}; - }, - }; -})(); -Object.defineProperty(window, "localStorage", { value: localStorageMock }); - -// Minimal stubs to avoid Next.js router and network usage during render -vi.mock("@/components/networking", () => ({ - credentialListCall: vi.fn().mockResolvedValue({ credentials: [] }), - modelInfoCall: vi.fn().mockResolvedValue({ data: [] }), - modelCostMap: vi.fn().mockResolvedValue({}), - getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: {} }), - getCallbacksCall: vi.fn().mockResolvedValue({ router_settings: {} }), - setCallbacksCall: vi.fn().mockResolvedValue(undefined), - getUiSettings: vi.fn().mockResolvedValue({ values: {} }), - latestHealthChecksCall: vi.fn().mockResolvedValue({ latest_health_checks: {} }), - getModelCostMapReloadStatus: vi.fn().mockResolvedValue({}), -})); - -vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/ModelAnalyticsTab", () => ({ - default: () => null, -})); - -vi.mock("@/components/add_model/add_auto_router_tab", () => ({ - default: () => null, -})); - -vi.mock("@/components/add_model/AddModelForm", () => ({ - default: () => null, -})); - -const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null); -vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({ - default: (props: { all_models_on_proxy?: string[] }) => { - mockHealthCheckComponent(props); - return null; - }, -})); - -vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ - default: () => ({ - teams: [], - setTeams: vi.fn(), - }), -})); - -const mockUseModelsInfo = vi.fn(); -vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ - useModelsInfo: () => mockUseModelsInfo(), -})); - -const mockUseUISettings = vi.fn(); -vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ - useUISettings: () => mockUseUISettings(), -})); - -const mockUseModelCostMap = vi.fn(); -vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ - useModelCostMap: () => mockUseModelCostMap(), -})); - -const mockUseAuthorized = vi.fn(); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => mockUseAuthorized(), -})); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); - -describe("ModelsAndEndpointsView", () => { - beforeEach(() => { - mockUseModelsInfo.mockReturnValue({ - data: { data: [] }, - isLoading: false, - refetch: vi.fn(), - }); - mockUseUISettings.mockReturnValue({ - data: { values: {} }, - }); - mockUseModelCostMap.mockReturnValue({ - data: {}, - isLoading: false, - error: null, - }); - mockUseAuthorized.mockReturnValue({ - accessToken: "123", - token: "123", - userRole: "Admin", - userId: "123", - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (global as any).ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - }; - }); - - it("should render the models and endpoints view", async () => { - const queryClient = createQueryClient(); - const { findByText } = render( - - - , - ); - expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument(); - }); - - it("should show Cost Optimization feedback banner by default", async () => { - localStorageMock.clear(); - const queryClient = createQueryClient(); - const { findByText } = render( - - - , - ); - expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument(); - }); - - it("should hide Cost Optimization feedback banner when dismiss button is clicked and persist to localStorage", async () => { - localStorageMock.clear(); - const queryClient = createQueryClient(); - const { findByText, queryByText, container } = render( - - - , - ); - - // Wait for banner to appear - expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument(); - - // Find and click dismiss button (X button) - const dismissButton = container.querySelector('button[aria-label="Dismiss banner"]'); - expect(dismissButton).not.toBeNull(); - fireEvent.click(dismissButton!); - - // Banner should be hidden - expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument(); - - // LocalStorage should be updated - expect(localStorageMock.getItem("hideCostOptimizationFeedbackBanner")).toBe("true"); - }); - - it("should keep Cost Optimization feedback banner hidden across remounts once dismissed", async () => { - // Set localStorage to hide banner - localStorageMock.setItem("hideCostOptimizationFeedbackBanner", "true"); - const queryClient = createQueryClient(); - const { findByText, queryByText } = render( - - - , - ); - - // Wait for component to render - await findByText("Model Management", {}, { timeout: 10000 }); - - // Banner should not be visible - expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument(); - }); - - it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => { - mockHealthCheckComponent.mockClear(); - const modelDataWithIds = { - data: [ - { model_name: "gpt-4", model_info: { id: "deployment-id-1" } }, - { model_name: "gpt-4", model_info: { id: "deployment-id-2" } }, - ], - }; - mockUseModelsInfo.mockReturnValue({ - data: { data: modelDataWithIds.data }, - isLoading: false, - refetch: vi.fn(), - }); - - const queryClient = createQueryClient(); - const { getByRole } = render( - - - , - ); - - const healthStatusTab = getByRole("tab", { name: "Health Status" }); - await act(async () => { - healthStatusTab.click(); - }); - - expect(mockHealthCheckComponent).toHaveBeenCalled(); - const healthCheckProps = mockHealthCheckComponent.mock.calls[0][0]; - expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]); - expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4"); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx deleted file mode 100644 index 72f4fb8caa3..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ /dev/null @@ -1,487 +0,0 @@ -import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; -import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; -import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; -import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; -import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy"; -import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab"; -import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; -import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab"; -import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab"; -import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; -import { Team } from "@/components/key_team_helpers/key_list"; -import CredentialsPanel from "@/components/model_add/CredentialsPanel"; -import { getCallbacksCall } from "@/components/networking"; -import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers"; -import { getDisplayModelName } from "@/components/view_model/model_name_display"; -import { transformModelData } from "./utils/modelDataTransformer"; -import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles"; -import { RefreshIcon } from "@heroicons/react/outline"; -import { useQueryClient } from "@tanstack/react-query"; -import type { PaginationState } from "@tanstack/react-table"; -import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import type { UploadProps } from "antd"; -import { Form } from "antd"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import AddModelTab from "../../../components/add_model/add_model_tab"; -import HealthCheckComponent from "../../../components/model_dashboard/HealthCheckComponent"; -import ModelGroupAliasSettings from "../../../components/model_group_alias_settings"; -import ModelInfoView from "../../../components/model_info_view"; -import NotificationsManager from "../../../components/molecules/notifications_manager"; -import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings"; -import TeamInfoView from "../../../components/team/TeamInfo"; -import useAuthorized from "../hooks/useAuthorized"; - -interface ModelDashboardProps { - premiumUser: boolean; - teams: Team[] | null; -} - -interface RetryPolicyObject { - [key: string]: { [retryPolicyKey: string]: number } | undefined; -} - -interface GlobalRetryPolicyObject { - [retryPolicyKey: string]: number; -} - -interface RouterSettings { - model_group_retry_policy?: RetryPolicyObject | null; - retry_policy?: GlobalRetryPolicyObject | null; - num_retries?: number | null; - model_group_alias?: { [key: string]: string } | null; -} - -const HEALTH_PAGE_SIZE = 50; - -const ModelsAndEndpointsView: React.FC = ({ premiumUser, teams }) => { - const { accessToken, token, userRole, userId: userID } = useAuthorized(); - const [addModelForm] = Form.useForm(); - const [lastRefreshed, setLastRefreshed] = useState(""); - const [providerModels, setProviderModels] = useState>([]); - const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); - const [selectedModelGroup, setSelectedModelGroup] = useState(null); - - const [retryScope, setRetryScope] = useState("global"); - const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState(null); - const [globalRetryPolicy, setGlobalRetryPolicy] = useState(null); - const [defaultRetry, setDefaultRetry] = useState(0); - const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({}); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [selectedModelId, setSelectedModelId] = useState(null); - const [selectedTeamId, setSelectedTeamId] = useState(null); - const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [healthPagination, setHealthPagination] = useState({ - pageIndex: 0, - pageSize: HEALTH_PAGE_SIZE, - }); - - const queryClient = useQueryClient(); - const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo(); - const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo( - healthPagination.pageIndex + 1, - healthPagination.pageSize, - ); - const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); - const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials(); - const credentialsList = credentialsResponse?.credentials || []; - const { data: uiSettings, isLoading: isLoadingUISettings } = useUISettings(); - const updateRetryPolicy = useUpdateRetryPolicy(accessToken); - - const availableModelGroups = useMemo(() => { - if (!modelDataResponse?.data) return []; - const allModelGroups = new Set(); - for (const model of modelDataResponse.data) { - allModelGroups.add(model.model_name); - } - return Array.from(allModelGroups).sort(); - }, [modelDataResponse?.data]); - - const availableModelAccessGroups = useMemo(() => { - if (!modelDataResponse?.data) return []; - const allModelAccessGroups = new Set(); - for (const model of modelDataResponse.data) { - const modelInfo = model.model_info; - if (modelInfo?.access_groups) { - for (const group of modelInfo.access_groups) { - allModelAccessGroups.add(group); - } - } - } - return Array.from(allModelAccessGroups); - }, [modelDataResponse?.data]); - - const allModelsOnProxy = useMemo(() => { - if (!modelDataResponse?.data) return []; - return modelDataResponse.data.map((model: any) => model.model_name); - }, [modelDataResponse?.data]); - - const healthModelIdsOnProxy = useMemo(() => { - if (!healthModelDataResponse?.data) return []; - return healthModelDataResponse.data - .map((model: any) => model.model_info?.id) - .filter((id: string | undefined): id is string => Boolean(id)); - }, [healthModelDataResponse?.data]); - - const getProviderFromModel = (model: string) => { - if (modelCostMapData !== null && modelCostMapData !== undefined) { - if (typeof modelCostMapData == "object" && model in modelCostMapData) { - return modelCostMapData[model]["litellm_provider"]; - } - } - return "openai"; - }; - - const processedModelData = useMemo(() => { - if (!modelDataResponse?.data) return { data: [] }; - return transformModelData(modelDataResponse, getProviderFromModel); - }, [modelDataResponse?.data, getProviderFromModel]); - - const processedHealthModelData = useMemo(() => { - if (!healthModelDataResponse?.data) return { data: [] }; - return transformModelData(healthModelDataResponse, getProviderFromModel); - }, [healthModelDataResponse?.data, getProviderFromModel]); - - const healthRowCount = healthModelDataResponse?.total_count ?? 0; - - const isProxyAdmin = userRole && isProxyAdminRole(userRole); - const isInternalUser = userRole && internalUserRoles.includes(userRole); - const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams, userID); - const addModelDisabledForInternalUsers = - isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true; - // Hide tab if user is NOT a proxy admin AND (internal user with setting enabled OR not a team admin) - const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin); - - const setProviderModelsFn = (provider: Providers) => { - const _providerModels = getProviderModels(provider, modelCostMapData); - setProviderModels(_providerModels); - }; - - const uploadProps: UploadProps = { - name: "file", - accept: ".json", - pastable: false, - beforeUpload: (file) => { - if (file.type === "application/json") { - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target) { - const jsonStr = e.target.result as string; - addModelForm.setFieldsValue({ vertex_credentials: jsonStr }); - } - }; - reader.readAsText(file); - } - return false; - }, - onChange(info) { - if (info.file.status === "done") { - NotificationsManager.success(`${info.file.name} file uploaded successfully`); - } else if (info.file.status === "error") { - NotificationsManager.fromBackend(`${info.file.name} file upload failed.`); - } - }, - }; - - const handleRefreshClick = () => { - const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); - setHealthPagination((previous) => ({ ...previous, pageIndex: 0 })); - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - refetchModels(); - }; - - const fetchRouterSettings = useCallback(async (): Promise => { - if (!accessToken || !userID || !userRole) { - return null; - } - try { - const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole); - return routerSettingsInfo.router_settings; - } catch (error) { - console.error("Error fetching model data:", error); - return null; - } - }, [accessToken, userID, userRole]); - - const applyRouterSettings = useCallback((routerSettings: RouterSettings) => { - setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null); - setGlobalRetryPolicy(routerSettings.retry_policy ?? null); - setDefaultRetry(routerSettings.num_retries ?? 2); - setModelGroupAlias(routerSettings.model_group_alias || {}); - }, []); - - const loadRetrySettings = useCallback(async () => { - const routerSettings = await fetchRouterSettings(); - if (routerSettings) { - applyRouterSettings(routerSettings); - } - }, [fetchRouterSettings, applyRouterSettings]); - - const handleSaveRetrySettings = () => { - updateRetryPolicy.mutate( - { - retry_policy: globalRetryPolicy, - model_group_retry_policy: modelGroupRetryPolicy, - }, - { - onSuccess: () => { - NotificationsManager.success("Retry settings saved successfully"); - loadRetrySettings(); - }, - onError: () => { - NotificationsManager.fromBackend("Failed to save retry settings"); - }, - }, - ); - }; - - useEffect(() => { - if (!accessToken || !token || !userRole || !userID || !modelDataResponse) { - return; - } - let active = true; - void (async () => { - const routerSettings = await fetchRouterSettings(); - if (active && routerSettings) { - applyRouterSettings(routerSettings); - } - })(); - return () => { - active = false; - }; - }, [accessToken, token, userRole, userID, modelDataResponse, fetchRouterSettings, applyRouterSettings]); - - const isLoading = isLoadingModels || isLoadingModelCostMap || isLoadingCredentials || isLoadingUISettings; - - // Admin Viewer can view all models read-only — page render proceeds; the - // individual write-action tabs (Add Model, LLM Credentials, etc.) are - // gated separately below. - - const handleOk = async () => { - try { - const values = await addModelForm.validateFields(); - await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick); - } catch (error: any) { - const errorMessages = - error.errorFields - ?.map((field: any) => { - return `${field.name.join(".")}: ${field.errors.join(", ")}`; - }) - .join(" | ") || "Unknown validation error"; - NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`); - } - }; - - Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider); - // If a team is selected, render TeamInfoView in full page layout - if (selectedTeamId) { - return ( -
- setSelectedTeamId(null)} - accessToken={accessToken} - is_team_admin={userRole === "Admin"} - is_proxy_admin={userRole === "Proxy Admin"} - userModels={allModelsOnProxy} - editTeam={false} - onUpdate={handleRefreshClick} - premiumUser={premiumUser} - /> -
- ); - } - - return ( -
- - - {/* Model Management Header */} -
-
-

Model Management

- {!all_admin_roles.includes(userRole) ? ( -

Add models for teams you are an admin for.

- ) : ( -

Add and manage models for the proxy

- )} -
-
- - {/* Cost Optimization Feedback Banner */} - - {selectedModelId && !isLoading ? ( - { - setSelectedModelId(null); - }} - accessToken={accessToken} - userID={userID} - userRole={userRole} - onModelUpdate={(updatedModel) => { - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - handleRefreshClick(); - }} - modelAccessGroups={availableModelAccessGroups} - /> - ) : ( - (() => { - // Build a single source-of-truth list of {tab, panel} pairs. - // Conditionally-hidden tabs (e.g. "Add Model" for non-admin) get - // filtered out as a unit so tab indices and panel indices can - // never drift apart — Tremor's TabList and TabPanels filter - // falsy children inconsistently, which previously caused - // "click LLM Credentials, see nothing" for Admin Viewer. - const isAdmin = all_admin_roles.includes(userRole); - const visibleTabs: Array<{ tab: React.ReactElement; panel: React.ReactElement }> = [ - { - tab: {isAdmin ? "All Models" : "Your Models"}, - panel: ( - - ), - }, - ]; - if (!shouldHideAddModelTab) { - visibleTabs.push({ - tab: Add Model, - panel: ( - - - - ), - }); - } - if (isAdmin) { - visibleTabs.push( - { - tab: LLM Credentials, - panel: ( - - - - ), - }, - { - tab: Pass-Through Endpoints, - panel: ( - - - - ), - }, - { - tab: Health Status, - panel: ( - - - - ), - }, - { - tab: Model Retry Settings, - panel: ( - - ), - }, - { - tab: Model Group Alias, - panel: ( - - - - ), - }, - { - tab: Price Data Reload, - panel: , - }, - ); - } - return ( - - -
{visibleTabs.map((t) => t.tab)}
- -
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- {visibleTabs.map((t) => t.panel)} -
- ); - })() - )} - -
-
- ); -}; - -export default ModelsAndEndpointsView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx new file mode 100644 index 00000000000..7e60ca58fc3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Form } from "antd"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import AddModelTab from "@/components/add_model/add_model_tab"; +import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; +import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload"; + +export default function AddModelPage() { + const { accessToken, userRole } = useAuthorized(); + const [form] = Form.useForm(); + const queryClient = useQueryClient(); + const { data: modelCostMapData } = useModelCostMap(); + const { data: credentialsResponse } = useCredentials(); + const { data: teams } = useTeams(); + const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); + const [providerModels, setProviderModels] = useState([]); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + await handleAddModelSubmit(values, accessToken, form, refresh); + } catch (error: any) { + const errorMessages = + error.errorFields?.map((field: any) => `${field.name.join(".")}: ${field.errors.join(", ")}`).join(" | ") || + "Unknown validation error"; + NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`); + } + }; + + return ( + setProviderModels(getProviderModels(provider, modelCostMapData))} + getPlaceholder={getPlaceholder} + uploadProps={vertexCredentialsUploadProps(form)} + showAdvancedSettings={showAdvancedSettings} + setShowAdvancedSettings={setShowAdvancedSettings} + teams={teams ?? null} + credentials={credentialsResponse?.credentials || []} + accessToken={accessToken} + userRole={userRole} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 20c9e805a0d..6efc85c019b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -11,7 +11,7 @@ import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking"; import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons"; import { PaginationState, SortingState } from "@tanstack/react-table"; import { useQueryClient } from "@tanstack/react-query"; -import { Grid, TabPanel } from "@tremor/react"; +import { Grid } from "@tremor/react"; import { Badge, Button, Select, Skeleton, Space, Typography } from "antd"; import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; @@ -232,7 +232,7 @@ const AllModelsTab = ({ }; return ( - +
@@ -600,7 +600,7 @@ const AllModelsTab = ({ onCancel={() => setIsModelSettingsModalVisible(false)} onSuccess={() => setIsModelSettingsModalVisible(false)} /> - +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx index 5ff761b0663..a4e3c4b958c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx @@ -1,4 +1,4 @@ -import { Button, Select, SelectItem, TabPanel, Text, Title } from "@tremor/react"; +import { Button, Select, SelectItem, Text, Title } from "@tremor/react"; import { InputNumber } from "antd"; import React from "react"; @@ -64,7 +64,7 @@ const ModelRetrySettingsTab = ({ }; return ( - +
Retry Policy Scope: @@ -132,7 +132,7 @@ const ModelRetrySettingsTab = ({ - +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx new file mode 100644 index 00000000000..282cc2722db --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx @@ -0,0 +1,22 @@ +/* @vitest-environment jsdom */ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import PriceDataManagementTab from "./PriceDataManagementTab"; + +// Deliberately do NOT mock @tremor/react. These tab components render standalone +// (inside antd Tabs / directly as a route page), no longer inside a Tremor +// . A Tremor root renders nothing without that context, so +// this asserts the component's content is visible on its own — reverting the root +// back to makes the title disappear and fails this test. +vi.mock("@/components/price_data_reload", () => ({ default: () =>
reload
})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) })); +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ + useModelCostMap: () => ({ refetch: vi.fn() }), +})); + +describe("PriceDataManagementTab", () => { + it("renders its content standalone, without a Tremor TabGroup ancestor", () => { + const { getByText } = render(); + expect(getByText("Price Data Management")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx index d44d19879d5..9420643578c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx @@ -1,4 +1,4 @@ -import { TabPanel, Text, Title } from "@tremor/react"; +import { Text, Title } from "@tremor/react"; import PriceDataReload from "@/components/price_data_reload"; import React from "react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -9,7 +9,7 @@ const PriceDataManagementTab = () => { const { refetch: refetchModelCostMap } = useModelCostMap(); return ( - +
Price Data Management @@ -28,7 +28,7 @@ const PriceDataManagementTab = () => { className="w-full" />
- +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts new file mode 100644 index 00000000000..717fdc85e28 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts @@ -0,0 +1,52 @@ +/* @vitest-environment jsdom */ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useModelDetailRouting } from "./detailNavigation"; + +// The detail overlay is driven by ?model=/?team= on the current path. Under the +// /ui static mount a router.push to the same path (query-only change) is a no-op, +// so navigation goes through history.pushState (client-side, no full reload). +vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); + +describe("useModelDetailRouting", () => { + beforeEach(() => { + window.history.pushState(null, "", "/models-and-endpoints/"); + }); + + it("openModel sets ?model= via history.pushState (no full navigation)", () => { + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useModelDetailRouting()); + act(() => result.current.openModel("abc-1")); + expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1")); + spy.mockRestore(); + }); + + it("openTeam sets ?team= and drops any model param", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useModelDetailRouting()); + act(() => result.current.openTeam("team-9")); + const url = spy.mock.calls.at(-1)?.[2] as string; + expect(url).toContain("team=team-9"); + expect(url).not.toContain("model="); + spy.mockRestore(); + }); + + it("close removes both model and team params", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useModelDetailRouting()); + act(() => result.current.close()); + const url = spy.mock.calls.at(-1)?.[2] as string; + expect(url).not.toContain("model="); + expect(url).not.toContain("team="); + spy.mockRestore(); + }); + + it("reads modelId and teamId from the query string", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model=xyz"); + const { result } = renderHook(() => useModelDetailRouting()); + expect(result.current.modelId).toBe("xyz"); + expect(result.current.teamId).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts new file mode 100644 index 00000000000..5e120e42740 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts @@ -0,0 +1,51 @@ +import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +export interface ModelDetailRouting { + modelId: string | null; + teamId: string | null; + openModel: (id: string) => void; + openTeam: (id: string) => void; + close: () => void; +} + +function navigateWithParams(mutate: (params: URLSearchParams) => void): void { + const params = new URLSearchParams(window.location.search); + mutate(params); + const qs = params.toString(); + const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; + window.history.pushState(null, "", url); +} + +export function useModelDetailRouting(): ModelDetailRouting { + const searchParams = useSearchParams(); + + const openModel = useCallback((id: string) => { + navigateWithParams((params) => { + params.delete("team"); + params.set("model", id); + }); + }, []); + + const openTeam = useCallback((id: string) => { + navigateWithParams((params) => { + params.delete("model"); + params.set("team", id); + }); + }, []); + + const close = useCallback(() => { + navigateWithParams((params) => { + params.delete("model"); + params.delete("team"); + }); + }, []); + + return { + modelId: searchParams?.get("model") ?? null, + teamId: searchParams?.get("team") ?? null, + openModel, + openTeam, + close, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx new file mode 100644 index 00000000000..677796957cc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import HealthStatusPage from "./page"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/models-and-endpoints/health", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null); +vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({ + default: (props: { all_models_on_proxy?: string[] }) => { + mockHealthCheckComponent(props); + return null; + }, +})); + +vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({ + transformModelData: () => ({ data: [] }), +})); + +const mockUseModelsInfo = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() })); +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) })); + +describe("HealthStatusPage", () => { + beforeEach(() => { + mockHealthCheckComponent.mockClear(); + }); + + it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => { + mockUseModelsInfo.mockReturnValue({ + data: { + data: [ + { model_name: "gpt-4", model_info: { id: "deployment-id-1" } }, + { model_name: "gpt-4", model_info: { id: "deployment-id-2" } }, + ], + total_count: 2, + }, + isLoading: false, + }); + + render(); + + expect(mockHealthCheckComponent).toHaveBeenCalled(); + const props = mockHealthCheckComponent.mock.calls[0][0]; + expect(props.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]); + expect(props.all_models_on_proxy).not.toContain("gpt-4"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx new file mode 100644 index 00000000000..8942db5f002 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import type { PaginationState } from "@tanstack/react-table"; +import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent"; +import { getDisplayModelName } from "@/components/view_model/model_name_display"; +import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer"; +import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; + +const HEALTH_PAGE_SIZE = 50; + +export default function HealthStatusPage() { + const { accessToken } = useAuthorized(); + const { data: teams } = useTeams(); + const { data: modelCostMapData } = useModelCostMap(); + const { openModel } = useModelDetailRouting(); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE }); + const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize); + + const getProviderFromModel = useCallback( + (model: string) => { + if (modelCostMapData && typeof modelCostMapData === "object" && model in modelCostMapData) { + return modelCostMapData[model]["litellm_provider"]; + } + return "openai"; + }, + [modelCostMapData], + ); + + const processedHealthModelData = useMemo(() => { + if (!healthModelDataResponse?.data) { + return { data: [] }; + } + return transformModelData(healthModelDataResponse, getProviderFromModel); + }, [healthModelDataResponse, getProviderFromModel]); + + const healthModelIdsOnProxy = useMemo( + () => + healthModelDataResponse?.data + ?.map((model: any) => model.model_info?.id) + .filter((id: string | undefined): id is string => Boolean(id)) ?? [], + [healthModelDataResponse?.data], + ); + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx new file mode 100644 index 00000000000..d47d8b9a04e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx @@ -0,0 +1,126 @@ +/* @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ModelsAndEndpointsLayout from "./layout"; + +const { mockPush, mockReplace, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockReplace: vi.fn(), + navState: { pathname: "/models-and-endpoints", search: "" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush, replace: mockReplace }), + useSearchParams: () => new URLSearchParams(navState.search), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null })); +vi.mock("@/components/model_info_view", () => ({ + default: ({ modelId }: { modelId: string }) =>
model:{modelId}
, +})); +vi.mock("@/components/team/TeamInfo", () => ({ + default: ({ teamId }: { teamId: string }) =>
team:{teamId}
, +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: {} } }), +})); +vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({ + useModelDashboardData: () => ({ + availableModelGroups: [], + availableModelAccessGroups: [], + allModelsOnProxy: [], + isLoading: false, + }), +})); + +const renderLayout = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + +
CHILD
+
+
, + ); +}; + +describe("ModelsAndEndpointsLayout", () => { + beforeEach(() => { + navState.pathname = "/models-and-endpoints"; + navState.search = ""; + mockPush.mockClear(); + mockReplace.mockClear(); + mockUseAuthorized.mockReturnValue({ + accessToken: "123", + token: "123", + userRole: "Admin", + userId: "123", + premiumUser: false, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the admin tab bar and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument(); + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("navigates to a tab's path when its tab is clicked", async () => { + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Health Status" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/health\/$/)); + }); + + it("redirects to the base models path when the tab path is not permitted for the role", async () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" }, + }); + mockUseAuthorized.mockReturnValue({ + accessToken: "123", + token: "123", + userRole: "Internal User", + userId: "123", + premiumUser: false, + }); + navState.pathname = "/models-and-endpoints/llm-credentials"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); + + it("renders the model detail overlay from ?model and hides the tabs and page content", () => { + navState.search = "model=abc-123"; + const { getByTestId, queryByTestId, queryByRole } = renderLayout(); + expect(getByTestId("model-info")).toHaveTextContent("model:abc-123"); + expect(queryByTestId("tab-content")).toBeNull(); + expect(queryByRole("tab", { name: "Health Status" })).toBeNull(); + }); + + it("renders the team detail overlay from ?team", () => { + navState.search = "team=team-9"; + const { getByTestId, queryByTestId } = renderLayout(); + expect(getByTestId("team-info")).toHaveTextContent("team:team-9"); + expect(queryByTestId("tab-content")).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx new file mode 100644 index 00000000000..1aea5330c5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { Tabs } from "antd"; +import { RefreshIcon } from "@heroicons/react/outline"; +import { useQueryClient } from "@tanstack/react-query"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles"; +import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; +import ModelInfoView from "@/components/model_info_view"; +import TeamInfoView from "@/components/team/TeamInfo"; +import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes"; +import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; +import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; + +const BASE_TAB_KEY = "all-models"; + +const TAB_LABELS: Record = { + add: "Add Model", + "llm-credentials": "LLM Credentials", + "pass-through": "Pass-Through Endpoints", + health: "Health Status", + "retry-settings": "Model Retry Settings", + "model-group-alias": "Model Group Alias", + "price-data": "Price Data Reload", +}; + +export default function ModelsAndEndpointsLayout({ children }: { children: ReactNode }) { + const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + const { data: teams, isLoading: teamsLoading } = useTeams(); + const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings(); + const pathname = usePathname(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { modelId, teamId, close } = useModelDetailRouting(); + const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData(); + + const [lastRefreshed, setLastRefreshed] = useState(""); + + const isProxyAdmin = userRole && isProxyAdminRole(userRole); + const isInternalUser = userRole && internalUserRoles.includes(userRole); + const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID); + const addModelDisabledForInternalUsers = + isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true; + const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin); + const isAdmin = all_admin_roles.includes(userRole); + + const visibleSlugs = useMemo>( + () => [ + "", + ...(shouldHideAddModelTab ? [] : (["add"] as const)), + ...(isAdmin + ? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const) + : []), + ], + [shouldHideAddModelTab, isAdmin], + ); + + const activeSlug = slugFromPathname(pathname); + const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug); + const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY; + + useEffect(() => { + if (teamsLoading || uiSettingsLoading) { + return; + } + if (activeSlug !== "" && !isKnownSlug) { + window.location.replace(modelTabHref("")); + } + }, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]); + + const allModelsLabel = isAdmin ? "All Models" : "Your Models"; + const tabItems = visibleSlugs.map((slug) => { + const key = slug || BASE_TAB_KEY; + return { + key, + label: slug ? TAB_LABELS[slug] : allModelsLabel, + children: key === activeKey ? children : null, + }; + }); + + const handleRefreshClick = () => { + setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); + queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + }; + + const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + + if (teamId) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+

Model Management

+ {isAdmin ? ( +

Add and manage models for the proxy

+ ) : ( +

Add models for teams you are an admin for.

+ )} +
+
+ + + + {modelId ? ( + + ) : ( + router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))} + items={tabItems} + tabBarExtraContent={{ + right: ( +
+ {lastRefreshed && Last Refreshed: {lastRefreshed}} + +
+ ), + }} + /> + )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx new file mode 100644 index 00000000000..207ced5be0d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Form } from "antd"; +import CredentialsPanel from "@/components/model_add/CredentialsPanel"; +import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload"; + +export default function LlmCredentialsPage() { + const [form] = Form.useForm(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx new file mode 100644 index 00000000000..c06de353ddf --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ModelGroupAliasSettings from "@/components/model_group_alias_settings"; +import { getCallbacksCall } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function ModelGroupAliasPage() { + const { accessToken, userId: userID, userRole } = useAuthorized(); + const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({}); + + useEffect(() => { + if (!accessToken || !userID || !userRole) { + return; + } + let active = true; + void (async () => { + try { + const info = await getCallbacksCall(accessToken, userID, userRole); + if (active) { + setModelGroupAlias(info.router_settings?.model_group_alias || {}); + } + } catch (error) { + console.error("Error fetching model group alias:", error); + } + })(); + return () => { + active = false; + }; + }, [accessToken, userID, userRole]); + + return ( + + ); +} 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 7594ee2f492..546309cfcc1 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 @@ -1,11 +1,23 @@ "use client"; -import ModelsAndEndpointsView from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useState } from "react"; +import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab"; +import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; +import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; -export default function ModelsAndEndpointsPage() { - const { premiumUser } = useAuthorized(); - const { data: teams } = useTeams(); - return ; +export default function AllModelsPage() { + const [selectedModelGroup, setSelectedModelGroup] = useState(null); + const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); + const { openModel, openTeam } = useModelDetailRouting(); + + return ( + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx new file mode 100644 index 00000000000..4ba7b8b260b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PassThroughPage() { + const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx new file mode 100644 index 00000000000..b8f385be13f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab"; + +export default function PriceDataPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx new file mode 100644 index 00000000000..6442be3e54d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab"; +import { getCallbacksCall } from "@/components/networking"; +import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy"; +import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +interface RetryPolicyObject { + [key: string]: { [retryPolicyKey: string]: number } | undefined; +} + +interface GlobalRetryPolicyObject { + [retryPolicyKey: string]: number; +} + +interface RouterSettings { + model_group_retry_policy?: RetryPolicyObject | null; + retry_policy?: GlobalRetryPolicyObject | null; + num_retries?: number | null; +} + +export default function ModelRetrySettingsPage() { + const { accessToken, userId: userID, userRole } = useAuthorized(); + const { availableModelGroups } = useModelDashboardData(); + const updateRetryPolicy = useUpdateRetryPolicy(accessToken); + + const [retryScope, setRetryScope] = useState("global"); + const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState(null); + const [globalRetryPolicy, setGlobalRetryPolicy] = useState(null); + const [defaultRetry, setDefaultRetry] = useState(0); + + const fetchRetrySettings = useCallback(async () => { + if (!accessToken || !userID || !userRole) { + return null; + } + try { + const info = await getCallbacksCall(accessToken, userID, userRole); + return info.router_settings; + } catch (error) { + console.error("Error fetching router settings:", error); + return null; + } + }, [accessToken, userID, userRole]); + + const applyRetrySettings = useCallback((routerSettings: RouterSettings) => { + setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null); + setGlobalRetryPolicy(routerSettings.retry_policy ?? null); + setDefaultRetry(routerSettings.num_retries ?? 2); + }, []); + + useEffect(() => { + let active = true; + void (async () => { + const routerSettings = await fetchRetrySettings(); + if (active && routerSettings) { + applyRetrySettings(routerSettings); + } + })(); + return () => { + active = false; + }; + }, [fetchRetrySettings, applyRetrySettings]); + + const handleSaveRetrySettings = () => { + updateRetryPolicy.mutate( + { retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy }, + { + onSuccess: () => { + NotificationsManager.success("Retry settings saved successfully"); + void fetchRetrySettings().then((routerSettings) => { + if (routerSettings) { + applyRetrySettings(routerSettings); + } + }); + }, + onError: () => { + NotificationsManager.fromBackend("Failed to save retry settings"); + }, + }, + ); + }; + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts new file mode 100644 index 00000000000..920bd3dc156 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts @@ -0,0 +1,38 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { MODEL_TAB_SLUGS, modelTabHref, slugFromPathname } from "./tabRoutes"; + +describe("slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(slugFromPathname("/models-and-endpoints")).toBe(""); + expect(slugFromPathname("/models-and-endpoints/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(slugFromPathname("/models-and-endpoints/add")).toBe("add"); + expect(slugFromPathname("/ui/models-and-endpoints/llm-credentials/")).toBe("llm-credentials"); + }); + + it("returns the raw segment for an unknown tab so the view can redirect to base", () => { + expect(slugFromPathname("/ui/models-and-endpoints/bogus")).toBe("bogus"); + }); + + it("returns empty string when the models base segment is not in the path", () => { + expect(slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("modelTabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(modelTabHref("")).toBe("/ui/models-and-endpoints/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of MODEL_TAB_SLUGS) { + expect(modelTabHref(slug)).toBe(`/ui/models-and-endpoints/${slug}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts new file mode 100644 index 00000000000..ddf9546c5c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts @@ -0,0 +1,29 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export const MODELS_BASE_SEGMENT = "models-and-endpoints"; + +export const MODEL_TAB_SLUGS = [ + "add", + "llm-credentials", + "pass-through", + "health", + "retry-settings", + "model-group-alias", + "price-data", +] as const; + +export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number]; + +export function modelTabHref(slug: string): string { + const base = migratedHref(MODELS_BASE_SEGMENT); + return slug ? `${base}/${slug}/` : `${base}/`; +} + +export function slugFromPathname(pathname: string): string { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(MODELS_BASE_SEGMENT); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts new file mode 100644 index 00000000000..c793e41bfd1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; + +export interface ModelDashboardData { + availableModelGroups: string[]; + availableModelAccessGroups: string[]; + allModelsOnProxy: string[]; + isLoading: boolean; +} + +export function useModelDashboardData(): ModelDashboardData { + const { data: modelDataResponse, isLoading } = useModelsInfo(); + + const availableModelGroups = useMemo(() => { + const groups = new Set(modelDataResponse?.data?.map((model) => model.model_name) ?? []); + return Array.from(groups).sort(); + }, [modelDataResponse?.data]); + + const availableModelAccessGroups = useMemo(() => { + const groups = new Set( + modelDataResponse?.data?.flatMap((model) => model.model_info?.access_groups ?? []) ?? [], + ); + return Array.from(groups); + }, [modelDataResponse?.data]); + + const allModelsOnProxy = useMemo( + () => modelDataResponse?.data?.map((model) => model.model_name) ?? [], + [modelDataResponse?.data], + ); + + return { availableModelGroups, availableModelAccessGroups, allModelsOnProxy, isLoading }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts new file mode 100644 index 00000000000..61bfbd7a99f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts @@ -0,0 +1,29 @@ +import type { FormInstance, UploadProps } from "antd"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export function vertexCredentialsUploadProps(form: FormInstance): UploadProps { + return { + name: "file", + accept: ".json", + pastable: false, + beforeUpload: (file) => { + if (file.type === "application/json") { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target) { + form.setFieldsValue({ vertex_credentials: event.target.result as string }); + } + }; + reader.readAsText(file); + } + return false; + }, + onChange(info) { + if (info.file.status === "done") { + NotificationsManager.success(`${info.file.name} file uploaded successfully`); + } else if (info.file.status === "error") { + NotificationsManager.fromBackend(`${info.file.name} file upload failed.`); + } + }, + }; +}