From da271d7b2777c4e0995309f6fd76f1e89eb60372 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:18:43 +0000 Subject: [PATCH] feat(ui): allow internal viewers to list models --- .../model_management_endpoints.py | 5 +++ .../test_model_management_endpoints.py | 26 +++++++++++++ .../ModelsAndEndpointsView.test.tsx | 37 ++++++++++++++++++- .../ModelsAndEndpointsView.tsx | 13 +++++-- .../components/AllModelsTab.test.tsx | 31 ++++++++++++++++ .../components/AllModelsTab.tsx | 27 ++++++++------ .../src/components/leftnav.test.tsx | 22 +++++++++++ .../src/components/leftnav.tsx | 3 +- .../src/components/model_info_view.test.tsx | 11 ++++++ .../src/components/model_info_view.tsx | 7 +++- .../molecules/models/columns.test.tsx | 34 +++++++++++++++++ .../components/molecules/models/columns.tsx | 4 +- ui/litellm-dashboard/src/utils/roles.test.ts | 10 +++++ ui/litellm-dashboard/src/utils/roles.ts | 2 + 14 files changed, 213 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index d458d0f7c4a..3b521ea57b6 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -936,6 +936,11 @@ class ModelManagementAuthChecks: team_obj: Optional[LiteLLM_TeamTable] = None, premium_user: bool = False, ) -> Literal[True]: + if user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY: + raise HTTPException( + status_code=403, + detail={"error": "Internal user viewers cannot manage models."}, + ) if premium_user is False: raise HTTPException( status_code=403, diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 8c6bdefedae..c99563e32b8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -5,6 +5,7 @@ from typing import Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from litellm._uuid import uuid @@ -126,6 +127,11 @@ class TestModelManagementAuthChecks: team_id="test_team", user_role=LitellmUserRoles.INTERNAL_USER, ) + self.team_admin_viewer = UserAPIKeyAuth( + user_id="test_viewer", + team_id="test_team", + user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ) @pytest.mark.asyncio async def test_can_user_make_team_model_call_admin_success(self): @@ -165,6 +171,26 @@ class TestModelManagementAuthChecks: ) assert result is True + @pytest.mark.asyncio + async def test_can_user_make_team_model_call_internal_viewer_fails(self): + team_obj = LiteLLM_TeamTable( + team_id="test_team", + team_alias="test_team", + members_with_roles=[ + Member(user_id=self.team_admin_viewer.user_id, role="admin") + ], + ) + + with pytest.raises(HTTPException) as exc_info: + ModelManagementAuthChecks.can_user_make_team_model_call( + team_id="test_team", + user_api_key_dict=self.team_admin_viewer, + team_obj=team_obj, + premium_user=True, + ) + + assert exc_info.value.status_code == 403 + @pytest.mark.asyncio async def test_allow_team_model_action_success(self): """Test successful team model action""" 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 index ce6dd0879fa..839c931f5ec 100644 --- 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 @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelsAndEndpointsView from "./ModelsAndEndpointsView"; @@ -209,4 +209,39 @@ describe("ModelsAndEndpointsView", () => { expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]); expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4"); }); + + it("should hide Add Model from an Internal Viewer who is a team admin", async () => { + const internalViewerAuth = { + accessToken: "123", + token: "123", + userRole: "Internal Viewer", + userId: "viewer-123", + }; + mockUseAuthorized.mockReturnValue(internalViewerAuth); + const teams = [ + { + team_id: "team-1", + team_alias: "Team 1", + models: [], + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + organization_id: null, + created_at: "2024-01-01", + keys: [], + members_with_roles: [{ user_id: "viewer-123", role: "admin" }], + }, + ]; + + const queryClient = createQueryClient(); + const { findByText } = render( + + + , + ); + + expect(await findByText("Your Models", {}, { timeout: 10000 })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument(); + }); }); 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 index 4bf4af0c7f6..084cba1767b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -14,7 +14,13 @@ 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 { + all_admin_roles, + internalUserRoles, + internalViewerRoles, + isProxyAdminRole, + isUserTeamAdminForAnyTeam, +} from "@/utils/roles"; import { RefreshIcon } from "@heroicons/react/outline"; import { useQueryClient } from "@tanstack/react-query"; import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; @@ -148,11 +154,12 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te const isProxyAdmin = userRole && isProxyAdminRole(userRole); const isInternalUser = userRole && internalUserRoles.includes(userRole); + const isInternalViewer = userRole && internalViewerRoles.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 internalUserCannotAddModel = addModelDisabledForInternalUsers || !isUserTeamAdmin; + const shouldHideAddModelTab = isInternalViewer || (!isProxyAdmin && internalUserCannotAddModel); const setProviderModelsFn = (provider: Providers) => { const _providerModels = getProviderModels(provider, modelCostMapData); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 045bf0a5f44..d5deafc335d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -147,6 +147,37 @@ describe("AllModelsTab", () => { expect(screen.getByText("Current Team:")).toBeInTheDocument(); }); + it("should hide model settings from Internal Viewers", () => { + const internalViewerAuth = { + ...mockUseAuthorized, + userRole: "Internal Viewer", + }; + vi.spyOn(useAuthorizedModule, "default").mockReturnValue(internalViewerAuth); + mockUseModelsInfo.mockReturnValue({ + data: createPaginatedModelData([ + { + model_name: "viewer-model", + litellm_params: { model: "openai/gpt-4" }, + model_info: { + id: "viewer-model-id", + direct_access: true, + access_via_team_ids: [], + access_groups: [], + db_model: true, + created_by: "user-123", + }, + }, + ]), + isLoading: false, + error: null, + }); + + renderWithProviders(); + + expect(screen.getByText("viewer-model")).toBeInTheDocument(); + expect(screen.queryByTitle("Model Settings")).not.toBeInTheDocument(); + }); + it("should filter models by direct team access when current team is selected", async () => { const mockTeams = [ { 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..7ec6b84c037 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 @@ -18,6 +18,7 @@ import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { useEffect, useMemo, useState } from "react"; import { useModelsInfo } from "../../hooks/models/useModels"; import { transformModelData } from "../utils/modelDataTransformer"; +import { internalViewerRoles } from "@/utils/roles"; type ModelViewMode = "all" | "current_team"; const SEARCH_DEBOUNCE_WAIT_MS = 200; @@ -44,6 +45,7 @@ const AllModelsTab = ({ const { accessToken, userId, userRole, premiumUser } = useAuthorized(); const { data: teams, isLoading: isLoadingTeams } = useTeams(); const queryClient = useQueryClient(); + const isInternalViewer = internalViewerRoles.includes(userRole); const [modelNameSearch, setModelNameSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -429,12 +431,13 @@ const AllModelsTab = ({ - {/* Model Settings Button */} -