diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py index 8ac33b50297..108fb5aeb8e 100644 --- a/api/core/plugin/impl/plugin.py +++ b/api/core/plugin/impl/plugin.py @@ -98,19 +98,40 @@ class PluginInstaller(BasePluginClient): tags: Sequence[str] = (), language: str = "en_US", ) -> PluginListWithoutTotalResponse: - return self._request_with_plugin_daemon_response( - "GET", - f"plugin/{tenant_id}/management/{category.value}/list", - PluginListWithoutTotalResponse, - params={ - "page": page, - "page_size": page_size, - "response_type": "paged", - "query": query, - "tags": list(tags), - "language": language, - }, - ) + try: + return self._request_with_plugin_daemon_response( + "GET", + f"plugin/{tenant_id}/management/{category.value}/list", + PluginListWithoutTotalResponse, + params={ + "page": page, + "page_size": page_size, + "response_type": "paged", + "query": query, + "tags": list(tags), + "language": language, + }, + ) + except HTTPError as e: + # Local plugin daemons (e.g. 0.6.x) don't expose the + # category-segmented route. Fall back to the generic listing and + # filter by declaration.category on the API side. + message = e.args[0] if e.args else "" + if "404" not in message: + raise + all_plugins = self.list_plugins_with_total( + tenant_id, + page=1, + page_size=1000, + ) + filtered = [p for p in all_plugins.list if p.declaration.category == category] + start = (page - 1) * page_size + end = start + page_size + return PluginListWithoutTotalResponse( + list=filtered[start:end], + has_more=end < len(filtered), + ) + def upload_pkg( self, diff --git a/api/tests/unit_tests/core/plugin/test_plugin_manager.py b/api/tests/unit_tests/core/plugin/test_plugin_manager.py index 290c7301bbe..f8b63e1d166 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_manager.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_manager.py @@ -154,6 +154,40 @@ class TestPluginDiscovery: assert result.list == [mock_plugin_entity] assert result.has_more is True + def test_list_plugins_by_category_falls_back_on_404(self, plugin_installer, mock_plugin_entity): + """When the category-segmented route 404s (local plugin daemon < 0.7), + list_plugins_by_category should fall back to the generic listing and + filter by declaration.category on the API side. Regression for #40683. + """ + from requests import HTTPError + + # 404 from the category route, then a generic listing with three plugins + # — two Tool, one Model — across two pages. + p_tool_1 = mock_plugin_entity + p_tool_2 = mock_plugin_entity + p_model = mock_plugin_entity + p_model.declaration.category = PluginCategory.Model + + with patch.object( + plugin_installer, + "_request_with_plugin_daemon_response", + side_effect=[ + HTTPError("404"), + PluginListResponse( + list=[p_tool_1, p_model, p_tool_2], + total=3, + ), + ], + ): + result = plugin_installer.list_plugins_by_category( + "test-tenant", category=PluginCategory.Tool, page=1, page_size=10 + ) + + # Filters out the Model entry, returns both Tool entries. + assert result.list == [p_tool_1, p_tool_2] + assert result.has_more is False + assert result.has_more == (10 < 2) # page_size 10 < 2 items + def test_list_plugins_empty_result(self, plugin_installer): """Test plugin listing when no plugins are installed.""" # Arrange: Mock empty response