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 */}
- }
- onClick={() => setIsModelSettingsModalVisible(true)}
- title="Model Settings"
- />
+ {!isInternalViewer && (
+ }
+ onClick={() => setIsModelSettingsModalVisible(true)}
+ title="Model Settings"
+ />
+ )}
{/* Additional Filters */}
@@ -595,11 +598,13 @@ const AllModelsTab = ({
onOk={handleDeleteModel}
confirmLoading={deleteLoading}
/>
- setIsModelSettingsModalVisible(false)}
- onSuccess={() => setIsModelSettingsModalVisible(false)}
- />
+ {!isInternalViewer && (
+ setIsModelSettingsModalVisible(false)}
+ onSuccess={() => setIsModelSettingsModalVisible(false)}
+ />
+ )}
);
};
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 36c1ade67a7..c61bfa738bd 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -9,6 +9,7 @@ vi.mock("../utils/roles", () => {
internalUserRoles: ["internal"],
rolesWithWriteAccess: ["admin", "internal"],
rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"],
+ rolesAllowedToViewModels: ["admin", "internal", "admin_viewer", "internal_user_viewer"],
isAdminRole: (role: string) => role === "admin" || role === "admin_viewer",
isUserTeamAdminForAnyTeam: () => false,
};
@@ -188,6 +189,27 @@ describe("Sidebar (leftnav)", () => {
});
});
+ describe("Internal Viewer model access", () => {
+ const internalViewerAuth = {
+ userId: "internal-viewer-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal_user_viewer",
+ token: "test-token",
+ userEmail: "viewer@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ };
+
+ it("shows Models + Endpoints without exposing write-scoped pages", () => {
+ mockUseAuthorized.mockReturnValueOnce(internalViewerAuth);
+ renderWithProviders();
+ expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
+ expect(screen.queryByText("Playground")).not.toBeInTheDocument();
+ expect(screen.queryByText("Agents")).not.toBeInTheDocument();
+ });
+ });
+
it("should show Organizations tab for organization admins", () => {
mockUseAuthorized.mockReturnValueOnce({
userId: "org-admin-user-id",
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 92dd3c849ef..d924a470af3 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -69,6 +69,7 @@ import {
internalUserRoles,
isAdminRole,
isUserTeamAdminForAnyTeam,
+ rolesAllowedToViewModels,
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
} from "../utils/roles";
@@ -140,7 +141,7 @@ const menuGroups: MenuGroup[] = [
page: "models",
label: "Models + Endpoints",
icon: ,
- roles: rolesAllowedToViewWriteScopedPages,
+ roles: rolesAllowedToViewModels,
},
{
key: "agentic",
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 2546601b4db..f0ed6e8c587 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx
@@ -365,6 +365,17 @@ describe("ModelInfoView", () => {
});
});
+ it("should disable model mutations for an Internal Viewer who created the model", async () => {
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /test connection/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /update api key/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /delete model/i })).toBeDisabled();
+ expect(screen.queryByRole("button", { name: /edit settings/i })).not.toBeInTheDocument();
+ });
+ });
+
it("should display overview and raw JSON tabs", async () => {
render(, { wrapper });
await waitFor(() => {
diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx
index aaad6e5a474..c9f503e3b92 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.tsx
@@ -45,6 +45,7 @@ import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import NumericalInput from "./shared/numerical_input";
import { Tag } from "./tag_management/types";
import { getDisplayModelName } from "./view_model/model_name_display";
+import { internalViewerRoles } from "@/utils/roles";
interface ModelInfoViewProps {
modelId: string;
@@ -121,8 +122,9 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
- const canEditModel =
- (userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
+ const isInternalViewer = userRole !== null && internalViewerRoles.includes(userRole);
+ const isAdminOrModelCreator = userRole === "Admin" || modelData?.model_info?.created_by === userID;
+ const canEditModel = !isInternalViewer && isAdminOrModelCreator && modelData?.model_info?.db_model;
const isAdmin = userRole === "Admin";
const isAutoRouter =
modelData?.litellm_params?.auto_router_config != null ||
@@ -540,6 +542,7 @@ export default function ModelInfoView({
icon={}
onClick={handleTestConnection}
className="flex items-center gap-2"
+ disabled={isInternalViewer}
data-testid="test-connection-button"
>
Test Connection
diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
index 9628c706c83..67b0d386212 100644
--- a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
@@ -737,6 +737,40 @@ describe("columns", () => {
expect(onDeleteClick).toHaveBeenCalledWith("user-model");
});
+ it("should not allow Internal Viewers to delete models they created", async () => {
+ const user = userEvent.setup();
+ const onDeleteClick = vi.fn();
+ const cols = columns(
+ "Internal Viewer",
+ "model-creator",
+ defaultProps.premiumUser,
+ defaultProps.setSelectedModelId,
+ defaultProps.setSelectedTeamId,
+ defaultProps.getDisplayModelName,
+ defaultProps.handleEditClick,
+ defaultProps.handleRefreshClick,
+ defaultProps.expandedRows,
+ defaultProps.setExpandedRows,
+ onDeleteClick,
+ );
+
+ const model = createMockModel({
+ model_info: {
+ ...createMockModel().model_info,
+ db_model: true,
+ created_by: "model-creator",
+ id: "viewer-model",
+ },
+ });
+ render();
+
+ const deleteButton = screen.getByRole("button", {
+ name: /config model cannot be deleted/i,
+ });
+ await user.click(deleteButton);
+ expect(onDeleteClick).not.toHaveBeenCalled();
+ });
+
it("should disable delete for config models", () => {
const cols = columns(
"Admin",
diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
index d043d0820f8..f8b54b7f7f4 100644
--- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
@@ -4,6 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Badge, Icon } from "@tremor/react";
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
+import { internalViewerRoles } from "@/utils/roles";
import { ModelData } from "../../model_dashboard/types";
import { ProviderLogo } from "./ProviderLogo";
@@ -360,7 +361,8 @@ export const columns = (
enableResizing: false,
cell: ({ row }) => {
const model = row.original;
- const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
+ const canEditModel =
+ !internalViewerRoles.includes(userRole) && (userRole === "Admin" || model.model_info?.created_by === userID);
const isConfigModel = !model.model_info?.db_model;
const isAdmin = userRole === "Admin";
const isBlocked = model.model_info?.blocked === true;
diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts
index 9a8a5a9c0c4..74111925e0d 100644
--- a/ui/litellm-dashboard/src/utils/roles.test.ts
+++ b/ui/litellm-dashboard/src/utils/roles.test.ts
@@ -4,6 +4,7 @@ import {
isProxyAdminRole,
isUserTeamAdminForAnyTeam,
isUserTeamAdminForSingleTeam,
+ rolesAllowedToViewModels,
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
} from "./roles";
@@ -172,4 +173,13 @@ describe("roles", () => {
expect(rolesAllowedToViewWriteScopedPages.length).toBeGreaterThan(rolesWithWriteAccess.length);
});
});
+
+ describe("rolesAllowedToViewModels", () => {
+ it("includes internal viewers without granting other write-scoped pages", () => {
+ expect(rolesAllowedToViewModels).toContain("Internal Viewer");
+ expect(rolesAllowedToViewModels).toContain("internal_user_viewer");
+ expect(rolesAllowedToViewWriteScopedPages).not.toContain("Internal Viewer");
+ expect(rolesAllowedToViewWriteScopedPages).not.toContain("internal_user_viewer");
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts
index 38f8496c2ae..16f3697cd45 100644
--- a/ui/litellm-dashboard/src/utils/roles.ts
+++ b/ui/litellm-dashboard/src/utils/roles.ts
@@ -6,6 +6,7 @@ export const v2_admin_role_names = ["proxy_admin", "proxy_admin_viewer", "org_ad
export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names];
export const internalUserRoles = ["Internal User", "Internal Viewer", "internal_user", "internal_user_viewer"];
+export const internalViewerRoles = ["Internal Viewer", "internal_user_viewer"];
export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"];
export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
// Admin-tier read parity: Admin Viewer sees Models + Endpoints, Agents, and
@@ -13,6 +14,7 @@ export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
// Per the Admin Viewer principle: read parity with Proxy Admin, no writes,
// no cost-incurring actions (Playground stays gated by `rolesWithWriteAccess`).
export const rolesAllowedToViewWriteScopedPages = [...rolesWithWriteAccess, "Admin Viewer", "proxy_admin_viewer"];
+export const rolesAllowedToViewModels = [...rolesAllowedToViewWriteScopedPages, ...internalViewerRoles];
// Helper function to check if a role is in all_admin_roles
export const isAdminRole = (role: string): boolean => {