feat(ui): allow internal viewers to list models

This commit is contained in:
Devin AI 2026-07-14 04:18:43 +00:00
parent 6a213de9f4
commit da271d7b27
14 changed files with 213 additions and 19 deletions

View file

@ -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,

View file

@ -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"""

View file

@ -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(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser teams={teams} />
</QueryClientProvider>,
);
expect(await findByText("Your Models", {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument();
});
});

View file

@ -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<ModelDashboardProps> = ({ 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);

View file

@ -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(<AllModelsTab {...defaultProps} />);
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 = [
{

View file

@ -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<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
@ -429,12 +431,13 @@ const AllModelsTab = ({
</button>
</div>
{/* Model Settings Button */}
<Button
icon={<SettingOutlined />}
onClick={() => setIsModelSettingsModalVisible(true)}
title="Model Settings"
/>
{!isInternalViewer && (
<Button
icon={<SettingOutlined />}
onClick={() => setIsModelSettingsModalVisible(true)}
title="Model Settings"
/>
)}
</div>
{/* Additional Filters */}
@ -595,11 +598,13 @@ const AllModelsTab = ({
onOk={handleDeleteModel}
confirmLoading={deleteLoading}
/>
<ModelSettingsModal
isVisible={isModelSettingsModalVisible}
onCancel={() => setIsModelSettingsModalVisible(false)}
onSuccess={() => setIsModelSettingsModalVisible(false)}
/>
{!isInternalViewer && (
<ModelSettingsModal
isVisible={isModelSettingsModalVisible}
onCancel={() => setIsModelSettingsModalVisible(false)}
onSuccess={() => setIsModelSettingsModalVisible(false)}
/>
)}
</TabPanel>
);
};

View file

@ -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(<Sidebar {...defaultProps} />);
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",

View file

@ -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: <Network {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
roles: rolesAllowedToViewModels,
},
{
key: "agentic",

View file

@ -365,6 +365,17 @@ describe("ModelInfoView", () => {
});
});
it("should disable model mutations for an Internal Viewer who created the model", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} userRole="Internal Viewer" />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
await waitFor(() => {

View file

@ -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={<RefreshIcon className="h-4 w-4" />}
onClick={handleTestConnection}
className="flex items-center gap-2"
disabled={isInternalViewer}
data-testid="test-connection-button"
>
Test Connection

View file

@ -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(<TestTable data={[model]} columns={cols} />);
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",

View file

@ -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;

View file

@ -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");
});
});
});

View file

@ -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 => {