From 6c06beea4b2ab3f8d406c9c8c4f23117a2aedf82 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:23:00 +0000 Subject: [PATCH] feat(ui): control which usage views internal users can see --- .../proxy_setting_endpoints.py | 6 + .../test_proxy_setting_endpoints.py | 79 +++++++++++++ .../AdminSettings/UISettings/UISettings.tsx | 22 ++++ .../UsageViewVisibilitySettings.test.tsx | 88 ++++++++++++++ .../UsageViewVisibilitySettings.tsx | 109 ++++++++++++++++++ .../components/UsagePageView.test.tsx | 25 +++- .../UsagePage/components/UsagePageView.tsx | 39 +++++-- .../UsageViewSelect/UsageViewSelect.test.tsx | 105 ++++++++++++++++- .../UsageViewSelect/UsageViewSelect.tsx | 53 +++++++-- ui/litellm-dashboard/tsconfig.tsbuildinfo | 2 +- 10 files changed, 504 insertions(+), 24 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.tsx diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a8926d26047..5520b229755 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -110,6 +110,11 @@ class UISettings(BaseModel): description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.", ) + enabled_usage_views_internal_users: Optional[List[str]] = Field( + default=None, + description="List of Usage page view keys that internal users (non-admins) can select in the Usage view dropdown. If not set, all non-admin views are visible. Admins always see every view.", + ) + require_auth_for_public_ai_hub: bool = Field( default=False, description="If true, requires authentication for accessing the public AI Hub.", @@ -194,6 +199,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "disable_model_add_for_internal_users", "disable_team_admin_delete_team_user", "enabled_ui_pages_internal_users", + "enabled_usage_views_internal_users", "require_auth_for_public_ai_hub", "allow_public_health_readiness_details", "forward_client_headers_to_llm_api", diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 69845ec59c2..a4b1d51f042 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -1093,6 +1093,85 @@ class TestProxySettingEndpoints: stored_settings = json.loads(create_data["ui_settings"]) assert stored_settings["disable_model_add_for_internal_users"] is True + def test_update_ui_settings_persists_enabled_usage_views_internal_users( + self, mock_auth, monkeypatch + ): + """enabled_usage_views_internal_users must be allowlisted and persisted as a list.""" + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + payload = {"enabled_usage_views_internal_users": ["global"]} + + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["settings"]["enabled_usage_views_internal_users"] == ["global"] + + call_args = mock_prisma.db.litellm_uisettings.upsert.call_args + stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"]) + assert stored_settings["enabled_usage_views_internal_users"] == ["global"] + + def test_update_ui_settings_resets_enabled_usage_views_to_null( + self, mock_auth, monkeypatch + ): + """Sending null for enabled_usage_views_internal_users restores default behaviour.""" + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_existing = MagicMock() + mock_existing.ui_settings = json.dumps( + {"enabled_usage_views_internal_users": ["global"]} + ) + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock( + return_value=mock_existing + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + payload = {"enabled_usage_views_internal_users": None} + + try: + response = client.patch("/update/ui_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert data["settings"]["enabled_usage_views_internal_users"] is None + + call_args = mock_prisma.db.litellm_uisettings.upsert.call_args + stored_settings = json.loads(call_args.kwargs["data"]["update"]["ui_settings"]) + assert stored_settings["enabled_usage_views_internal_users"] is None + def test_update_ui_settings_ignores_non_allowlisted_value( self, mock_auth, monkeypatch ): diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index ec970c34873..8d92a186f7f 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -5,6 +5,7 @@ import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdat import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import NotificationManager from "@/components/molecules/notifications_manager"; import PageVisibilitySettings from "./PageVisibilitySettings"; +import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings"; import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd"; export default function UISettings() { @@ -21,6 +22,7 @@ export default function UISettings() { const enableProjectsUIProperty = schema?.properties?.enable_projects_ui; const enableChatUIProperty = schema?.properties?.enable_chat_ui; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; + const enabledUsageViewsProperty = schema?.properties?.enabled_usage_views_internal_users; const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users; const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users; @@ -72,6 +74,17 @@ export default function UISettings() { }); }; + const handleUpdateUsageViewVisibility = (settings: { enabled_usage_views_internal_users: string[] | null }) => { + updateSettings(settings, { + onSuccess: () => { + NotificationManager.success("Usage view visibility settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }); + }; + const handleToggleForwardClientHeaders = (checked: boolean) => { updateSettings( { forward_client_headers_to_llm_api: checked }, @@ -491,6 +504,15 @@ export default function UISettings() { isUpdating={isUpdating} onUpdate={handleUpdatePageVisibility} /> + + + + )} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.test.tsx new file mode 100644 index 00000000000..cc876921f92 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings"; + +vi.mock("@/components/UsagePage/components/UsageViewSelect/UsageViewSelect", () => ({ + getConfigurableNonAdminUsageViews: () => [ + { value: "global", label: "Your Usage", description: "View your usage" }, + { value: "organization", label: "Your Organization Usage", description: "View your organization's usage" }, + { value: "team", label: "Team Usage", description: "View usage by team" }, + { value: "tag", label: "Tag Usage", description: "View usage grouped by tags" }, + ], +})); + +describe("UsageViewVisibilitySettings", () => { + it("should render the not-set tag when enabledViewsInternalUsers is null", () => { + render(); + expect(screen.getByText("Not set (all views visible)")).toBeInTheDocument(); + }); + + it("should show the selected view count tag when views are configured", () => { + render( + , + ); + expect(screen.getByText("2 views selected")).toBeInTheDocument(); + }); + + it("should show singular 'view' when exactly one view is selected", () => { + render( + , + ); + expect(screen.getByText("1 view selected")).toBeInTheDocument(); + }); + + it("should save only the selected views", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /configure usage view visibility/i })); + await user.click(await screen.findByRole("button", { name: /save usage view visibility settings/i })); + + expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: ["global"] }); + }); + + it("should save null when no views are selected", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /configure usage view visibility/i })); + await user.click(await screen.findByRole("checkbox", { name: /your usage/i })); + await user.click(screen.getByRole("button", { name: /save usage view visibility settings/i })); + + expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null }); + }); + + it("should call onUpdate with null when reset button is clicked", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /configure usage view visibility/i })); + await user.click(await screen.findByRole("button", { name: /reset to default/i })); + + expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null }); + }); + + it("should display the property description when provided", () => { + render( + , + ); + expect(screen.getByText("Controls which usage views are visible")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.tsx new file mode 100644 index 00000000000..c8ecec3214c --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UsageViewVisibilitySettings.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { getConfigurableNonAdminUsageViews } from "@/components/UsagePage/components/UsageViewSelect/UsageViewSelect"; +import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd"; +import { useMemo, useState } from "react"; + +interface UsageViewVisibilitySettingsProps { + enabledViewsInternalUsers: string[] | null | undefined; + enabledViewsPropertyDescription?: string; + isUpdating: boolean; + onUpdate: (settings: { enabled_usage_views_internal_users: string[] | null }) => void; +} + +export default function UsageViewVisibilitySettings({ + enabledViewsInternalUsers, + enabledViewsPropertyDescription, + isUpdating, + onUpdate, +}: UsageViewVisibilitySettingsProps) { + const isVisibilitySet = enabledViewsInternalUsers !== null && enabledViewsInternalUsers !== undefined; + + const availableViews = useMemo(() => getConfigurableNonAdminUsageViews(), []); + + const [selectedViews, setSelectedViews] = useState(enabledViewsInternalUsers ?? []); + const [syncedFrom, setSyncedFrom] = useState(enabledViewsInternalUsers); + if (syncedFrom !== enabledViewsInternalUsers) { + setSyncedFrom(enabledViewsInternalUsers); + setSelectedViews(enabledViewsInternalUsers ?? []); + } + + const handleSave = () => { + onUpdate({ enabled_usage_views_internal_users: selectedViews.length > 0 ? selectedViews : null }); + }; + + const handleResetToDefault = () => { + setSelectedViews([]); + onUpdate({ enabled_usage_views_internal_users: null }); + }; + + return ( + + + + Internal User Usage View Visibility + {!isVisibilitySet && ( + + Not set (all views visible) + + )} + {isVisibilitySet && ( + + {selectedViews.length} view{selectedViews.length !== 1 ? "s" : ""} selected + + )} + + {enabledViewsPropertyDescription && ( + {enabledViewsPropertyDescription} + )} + + By default, all non-admin views are visible to internal users in the Usage page dropdown. Select specific + views to restrict which options they can choose. + + + Note: Admins always see every usage view regardless of this setting. + + + + + + + {availableViews.map((view) => ( +
+ + + {view.label} + + {view.description} + + + +
+ ))} +
+
+ + + + {isVisibilitySet && ( + + )} + +
+ ), + }, + ]} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 7d5aec5f0b8..010f59e85c6 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -77,7 +77,26 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { ); }; UsageViewSelect.displayName = "UsageViewSelect"; - return { UsageViewSelect }; + const getVisibleUsageOptions = ({ + isAdmin, + canViewTagUsage = false, + enabledViews = null, + }: { + isAdmin: boolean; + canViewTagUsage?: boolean; + enabledViews?: string[] | null; + }) => { + const base = isAdmin + ? ["global", "my-usage", "organization", "team", "customer", "tag", "agent", "user", "user-agent-activity"] + : ["global", "organization", "team", ...(canViewTagUsage ? ["tag"] : [])]; + if (!isAdmin && enabledViews != null) { + return base.filter((v) => enabledViews.includes(v)); + } + return base; + }; + const resolveActiveUsageView = (current: string, visibleViews: string[]) => + visibleViews.length === 0 || visibleViews.includes(current) ? current : visibleViews[0]; + return { UsageViewSelect, getVisibleUsageOptions, resolveActiveUsageView }; }); vi.mock("../../shared/advanced_date_picker", async () => { @@ -126,6 +145,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ useInfiniteUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: vi.fn(() => ({ data: { values: {}, field_schema: {} } })), +})); + vi.mock("antd", async (importOriginal) => { const React = await import("react"); const actual = await importOriginal(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 37c2a25c6a6..980ded1a313 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -50,7 +50,13 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import TopKeyView from "./EntityUsage/TopKeyView"; import UsageAIChatPanel from "./UsageAIChatPanel"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { + getVisibleUsageOptions, + resolveActiveUsageView, + UsageOption, + UsageViewSelect, +} from "./UsageViewSelect/UsageViewSelect"; interface UsagePageProps { teams: Team[]; @@ -83,6 +89,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const { data: currentUser } = useCurrentUser(); const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); + const { data: uiSettings } = useUISettings(); + const enabledUsageViews = (uiSettings?.values?.enabled_usage_views_internal_users ?? null) as UsageOption[] | null; + const visibleUsageOptions = useMemo( + () => getVisibleUsageOptions({ isAdmin, canViewTagUsage, enabledViews: enabledUsageViews }), + [isAdmin, canViewTagUsage, enabledUsageViews], + ); // Debounced search for user selector const [userSearchInput, setUserSearchInput] = useState(""); @@ -151,8 +163,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { } }, [isAdmin, userID]); + const effectiveUsageView = resolveActiveUsageView(usageView, visibleUsageOptions); + // For non-admins or "my-usage" view, always pass their own user_id - const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId; + const effectiveUserId = effectiveUsageView === "my-usage" || !isAdmin ? userID || null : selectedUserId; const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); @@ -444,10 +458,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
setUsageView(value)} isAdmin={isAdmin} canViewTagUsage={canViewTagUsage} + enabledViews={enabledUsageViews} />
@@ -489,9 +504,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} {/* Your Usage / Global Usage Panel */} - {(usageView === "global" || usageView === "my-usage") && ( + {(effectiveUsageView === "global" || effectiveUsageView === "my-usage") && ( <> - {isAdmin && usageView === "global" && ( + {isAdmin && effectiveUsageView === "global" && (
Filter by user