mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(ui): allow internal viewers to list models
This commit is contained in:
parent
6a213de9f4
commit
da271d7b27
14 changed files with 213 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue