diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index ea634289cb..3a609eec12 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -178,6 +178,11 @@ class UISettings(BaseModel): description="If true, org admins cannot generate API keys via /key/generate.", ) + disable_ui_nudges: bool = Field( + default=False, + description="If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -201,6 +206,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "scope_user_search_to_org", "disable_custom_api_keys", "disable_key_generate_for_org_admin", + "disable_ui_nudges", } # Flags that must be synced from the persisted UISettings into 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 ae217aca16..f77af2d90b 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 @@ -1032,6 +1032,45 @@ 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_disable_ui_nudges( + self, mock_auth, monkeypatch + ): + """disable_ui_nudges must be allowlisted so admins can suppress UI popups for everyone""" + 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) + + try: + response = client.patch( + "/update/ui_settings", json={"disable_ui_nudges": True} + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["settings"]["disable_ui_nudges"] is True + + create_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"][ + "create" + ] + stored_settings = json.loads(create_data["ui_settings"]) + assert stored_settings["disable_ui_nudges"] is True + def test_update_ui_settings_ignores_non_allowlisted_value( self, mock_auth, monkeypatch ): diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 12dd39a1c2..81cce93099 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -9,6 +9,7 @@ import BudgetPanel from "@/components/budgets/budget_panel"; import CacheDashboard from "@/components/cache_dashboard"; import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import GeneralSettings from "@/components/general_settings"; @@ -83,6 +84,9 @@ function CreateKeyPageContent() { const [modelData, setModelData] = useState({ data: [] }); const [createClicked, setCreateClicked] = useState(false); + const { data: uiSettingsData, isLoading: uiSettingsLoading } = useUISettings(); + const nudgesDisabled = uiSettingsLoading || Boolean(uiSettingsData?.values?.disable_ui_nudges); + // Survey state - always show by default const [showSurveyPrompt, setShowSurveyPrompt] = useState(true); const [showSurveyModal, setShowSurveyModal] = useState(false); @@ -258,6 +262,9 @@ function CreateKeyPageContent() { // Fetch in-product nudges configuration from backend useEffect(() => { + if (nudgesDisabled) { + return; + } if (accessToken && token) { (async () => { try { @@ -277,7 +284,7 @@ function CreateKeyPageContent() { } })(); } - }, [accessToken, token]); + }, [accessToken, token, nudgesDisabled]); // Auto-dismiss survey prompt after 15 seconds useEffect(() => { @@ -541,7 +548,7 @@ function CreateKeyPageContent() { {/* Survey Components */} @@ -553,7 +560,7 @@ function CreateKeyPageContent() { {/* Claude Code Components */} 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 9ce0d90883..25865c48f9 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -26,6 +26,7 @@ export default function UISettings() { const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys; + const disableUINudgesProperty = schema?.properties?.disable_ui_nudges; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -60,6 +61,20 @@ export default function UISettings() { ); }; + const handleToggleDisableUINudges = (checked: boolean) => { + updateSettings( + { disable_ui_nudges: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => { updateSettings(settings, { onSuccess: () => { @@ -451,6 +466,26 @@ export default function UISettings() { + {/* Disable in-product UI nudges */} + + + + Disable UI nudges + + {disableUINudgesProperty?.description ?? + "If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users."} + + + + + + {/* Page Visibility for Internal Users */} { return { // Called on mount; we don't care about its contents, only that it resolves getUiConfig: vi.fn().mockResolvedValue({}), + // Fetched by useUISettings(); resolve with empty settings so nudges stay default-on + getUiSettings: vi.fn().mockResolvedValue({ values: {}, field_schema: {} }), // Used to build the redirect URL proxyBaseUrl: "https://example.com", // Called when decoding a valid token @@ -146,17 +148,22 @@ vi.mock("@/lib/cva.config", () => ({ cx: (...args: string[]) => args.join(" "), })); +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import CreateKeyPage from "@/app/page"; import { AuthProvider } from "@/contexts/AuthContext"; // The page consumes auth state via useAuth(). Wrap it so the hook resolves // against a real provider — the provider's effects (cookie read, JWT decode, -// redirect-on-expired) are what these tests exercise. +// redirect-on-expired) are what these tests exercise. The QueryClientProvider +// mirrors what layout.tsx supplies in production for hooks like useUISettings. function PageUnderTest() { + const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } })); return ( - - - + + + + + ); }