fix(api): include node plugins in DSL dependencies (#42816)

This commit is contained in:
林玮 (Jade Lin)
2026-09-23 08:53:02 +00:00
committed by GitHub
parent 8b7f903cff
commit 11883543d1
8 changed files with 300 additions and 18 deletions
+5 -2
View File
@@ -1027,12 +1027,15 @@ class AppDslService:
dependencies = []
for node in graph.get("nodes", []):
try:
typ = node.get("data", {}).get("type")
node_data = node.get("data", {})
dependencies.extend(DependenciesAnalysisService.extract_external_node_dependencies(node_data))
typ = node_data.get("type")
match typ:
case BuiltinNodeTypes.TOOL:
tool_entity = ToolNodeData.model_validate(node["data"])
dependencies.append(
DependenciesAnalysisService.analyze_tool_dependency(tool_entity.provider_id),
node_data.get("plugin_id")
or DependenciesAnalysisService.analyze_tool_provider_reference(tool_entity.provider_id),
)
case BuiltinNodeTypes.LLM:
llm_entity = LLMNodeData.model_validate(node["data"])
+66 -1
View File
@@ -1,16 +1,81 @@
import re
from collections.abc import Mapping
from typing import Any
from werkzeug.exceptions import NotFound
from configs import dify_config
from core.helper import marketplace
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType, PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
from models.provider_ids import ModelProviderID, ToolProviderID
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from graphon.enums import BuiltinNodeTypes
from models.provider_ids import GenericProviderID, ModelProviderID, ToolProviderID
# Compile regex pattern for version extraction at module level for better performance
_VERSION_REGEX = re.compile(r":(?P<version>[0-9]+(?:\.[0-9]+){2}(?:[+-][0-9A-Za-z.-]+)?)(?:@|$)")
class DependenciesAnalysisService:
@classmethod
def analyze_tool_provider_reference(cls, provider: str) -> str:
if re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", provider):
return provider
return cls.analyze_tool_dependency(provider)
@classmethod
def extract_external_node_dependencies(cls, node_data: Mapping[str, Any]) -> list[str]:
"""Extract plugin IDs from workflow nodes that contain direct plugin references."""
node_type = node_data.get("type")
if node_type == TRIGGER_PLUGIN_NODE_TYPE:
plugin_id = node_data.get("plugin_id")
return [plugin_id] if isinstance(plugin_id, str) and plugin_id else []
if node_type == BuiltinNodeTypes.DATASOURCE:
plugin_id = node_data.get("plugin_id")
if node_data.get("provider_type") != "local_file" and isinstance(plugin_id, str) and plugin_id:
return [plugin_id]
return []
if node_type != BuiltinNodeTypes.AGENT or node_data.get("agent_node_kind") == "dify_agent":
return []
dependencies = []
strategy_provider = node_data.get("agent_strategy_provider_name")
if isinstance(strategy_provider, str) and strategy_provider:
try:
if re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", strategy_provider):
dependencies.append(strategy_provider)
else:
dependencies.append(GenericProviderID(strategy_provider).plugin_id)
except (ValueError, NotFound):
pass
parameters = node_data.get("agent_parameters")
if not isinstance(parameters, Mapping):
return dependencies
for parameter in parameters.values():
if not isinstance(parameter, Mapping) or not isinstance(parameter.get("value"), list):
continue
for tool in parameter["value"]:
if not isinstance(tool, Mapping):
continue
provider_type = tool.get("provider_type", tool.get("type"))
if provider_type not in (None, "builtin", "plugin"):
continue
plugin_id = tool.get("plugin_id")
if isinstance(plugin_id, str) and plugin_id:
dependencies.append(plugin_id)
continue
provider = tool.get("provider_id") or tool.get("provider_name") or tool.get("provider")
if not isinstance(provider, str):
continue
try:
dependencies.append(cls.analyze_tool_provider_reference(provider))
except (ValueError, NotFound):
pass
return dependencies
@classmethod
def analyze_tool_dependency(cls, tool_id: str) -> str:
"""
@@ -772,12 +772,16 @@ class RagPipelineDslService:
dependencies = []
for node in graph.get("nodes", []):
try:
typ = node.get("data", {}).get("type")
node_data = node.get("data", {})
typ = node_data.get("type")
if typ != BuiltinNodeTypes.DATASOURCE:
dependencies.extend(DependenciesAnalysisService.extract_external_node_dependencies(node_data))
match typ:
case BuiltinNodeTypes.TOOL:
tool_entity = ToolNodeData.model_validate(node["data"])
dependencies.append(
DependenciesAnalysisService.analyze_tool_dependency(tool_entity.provider_id),
node_data.get("plugin_id")
or DependenciesAnalysisService.analyze_tool_provider_reference(tool_entity.provider_id),
)
case BuiltinNodeTypes.DATASOURCE:
datasource_entity = DatasourceNodeData.model_validate(node["data"])
+36 -13
View File
@@ -624,20 +624,43 @@ class SnippetDslService:
node_data = node.get("data", {})
if not node_data:
continue
dependencies.extend(DependenciesAnalysisService.extract_external_node_dependencies(node_data))
data_type = node_data.get("type", "")
if data_type == BuiltinNodeTypes.TOOL:
tool_config = node_data.get("tool_configurations", {})
provider_type = tool_config.get("provider_type")
provider_name = tool_config.get("provider")
if provider_type and provider_name:
dependencies.append(f"{provider_name}/{provider_name}")
elif data_type == BuiltinNodeTypes.AGENT:
agent_parameters = node_data.get("agent_parameters", {})
tools = agent_parameters.get("tools", {}).get("value", [])
for tool in tools:
provider_type = tool.get("provider_type")
provider_name = tool.get("provider")
if provider_type and provider_name:
dependencies.append(f"{provider_name}/{provider_name}")
tool_config = node_data.get("tool_configurations") or {}
provider_type = node_data.get("provider_type") or tool_config.get("provider_type")
if provider_type in ("builtin", "plugin"):
provider_id = (
node_data.get("plugin_id") or node_data.get("provider_id") or tool_config.get("provider")
)
if isinstance(provider_id, str) and provider_id:
dependencies.append(DependenciesAnalysisService.analyze_tool_provider_reference(provider_id))
elif data_type in (
BuiltinNodeTypes.LLM,
BuiltinNodeTypes.QUESTION_CLASSIFIER,
BuiltinNodeTypes.PARAMETER_EXTRACTOR,
):
model = node_data.get("model")
if isinstance(model, Mapping) and isinstance(model.get("provider"), str) and model["provider"]:
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(model["provider"])
)
elif data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
if node_data.get("retrieval_mode") == "single":
model_config = node_data.get("single_retrieval_config") or {}
model = model_config.get("model") or {}
provider = model.get("provider")
else:
multiple_config = node_data.get("multiple_retrieval_config") or {}
if multiple_config.get("reranking_mode") == "reranking_model":
provider = (multiple_config.get("reranking_model") or {}).get("provider")
elif multiple_config.get("reranking_mode") == "weighted_score":
provider = ((multiple_config.get("weights") or {}).get("vector_setting") or {}).get(
"embedding_provider_name"
)
else:
provider = None
if isinstance(provider, str) and provider:
dependencies.append(DependenciesAnalysisService.analyze_model_provider_dependency(provider))
return dependencies
@@ -28,6 +28,9 @@ class TestAnalyzeToolDependency:
with pytest.raises(ValueError):
DependenciesAnalysisService.analyze_tool_dependency("bad/format")
def test_plugin_id_reference_is_preserved(self):
assert DependenciesAnalysisService.analyze_tool_provider_reference("acme/search") == "acme/search"
class TestAnalyzeModelProviderDependency:
def test_valid_three_part_id(self):
@@ -43,6 +46,73 @@ class TestAnalyzeModelProviderDependency:
assert result == "langgenius/anthropic"
class TestExtractExternalNodeDependencies:
def test_legacy_agent_includes_strategy_and_plugin_tools(self):
node_data = {
"type": "agent",
"agent_strategy_provider_name": "langgenius/agent/agent",
"agent_parameters": {
"tools": {
"value": [
{"type": "builtin", "provider_name": "langgenius/search/search"},
{"provider_type": "plugin", "plugin_id": "acme/custom"},
{"type": "api", "provider_name": "custom-api"},
{"type": "builtin", "provider_name": "bad/provider/format/extra"},
]
}
},
}
assert DependenciesAnalysisService.extract_external_node_dependencies(node_data) == [
"langgenius/agent",
"langgenius/search",
"acme/custom",
]
def test_legacy_agent_accepts_plugin_id_strategy_and_legacy_tool_provider(self):
node_data = {
"type": "agent",
"agent_strategy_provider_name": "acme/strategy",
"agent_parameters": {
"tools": {
"value": [
{"provider_type": "builtin", "provider": "acme/legacy"},
{"type": "builtin", "provider_name": "search"},
]
},
"unrelated": {"value": "text"},
},
}
assert DependenciesAnalysisService.extract_external_node_dependencies(node_data) == [
"acme/strategy",
"acme/legacy",
"langgenius/search",
]
def test_legacy_agent_skips_invalid_strategy_and_non_tool_parameters(self):
node_data = {
"type": "agent",
"agent_strategy_provider_name": "invalid/provider/with/extra",
"agent_parameters": {"choices": {"value": ["plain text", {"type": "api", "provider_id": "custom-api"}]}},
}
assert DependenciesAnalysisService.extract_external_node_dependencies(node_data) == []
@pytest.mark.parametrize(
("node_data", "expected"),
[
({"type": "trigger-plugin", "plugin_id": "acme/trigger"}, ["acme/trigger"]),
({"type": "datasource", "provider_type": "online_document", "plugin_id": "acme/drive"}, ["acme/drive"]),
({"type": "datasource", "provider_type": "local_file", "plugin_id": "langgenius/file"}, []),
({"type": "agent", "agent_node_kind": "dify_agent"}, []),
({"type": "agent", "agent_parameters": "invalid"}, []),
],
)
def test_direct_plugin_references(self, node_data, expected):
assert DependenciesAnalysisService.extract_external_node_dependencies(node_data) == expected
class TestGetLeakedDependencies:
def _make_dependency(self, identifier: str, dep_type=PluginDependencyType.Marketplace):
return PluginDependency(
@@ -329,6 +329,25 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe
analyze_dependency.assert_called_once_with("old-provider")
def test_extract_dependencies_from_workflow_graph_includes_trigger_and_agent(
service: RagPipelineDslService, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
module.ToolNodeData,
"model_validate",
Mock(return_value=SimpleNamespace(provider_id="acme/tool/provider")),
)
graph = {
"nodes": [
{"data": {"type": BuiltinNodeTypes.TOOL, "plugin_id": "acme/tool"}},
{"data": {"type": "trigger-plugin", "plugin_id": "acme/trigger"}},
{"data": {"type": BuiltinNodeTypes.AGENT, "agent_strategy_provider_name": "acme/strategy/provider"}},
]
}
assert service._extract_dependencies_from_workflow_graph(graph) == ["acme/tool", "acme/trigger", "acme/strategy"]
def test_extract_dependencies_from_workflow_graph_covers_plugin_and_model_nodes(
monkeypatch: pytest.MonkeyPatch, service: RagPipelineDslService
) -> None:
@@ -175,6 +175,29 @@ def test_copy_app_scopes_result_to_current_tenant(sqlite_session: Session, monke
assert copied is None
def test_extract_workflow_dependencies_includes_plugin_nodes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"services.app_dsl_service.ToolNodeData.model_validate",
Mock(return_value=SimpleNamespace(provider_id="acme/tool/provider")),
)
graph = {
"nodes": [
{"data": {"type": "tool", "plugin_id": "acme/tool"}},
{"data": {"type": "trigger-plugin", "plugin_id": "acme/trigger"}},
{"data": {"type": "datasource", "provider_type": "online_document", "plugin_id": "acme/drive"}},
{"data": {"type": "datasource", "provider_type": "local_file", "plugin_id": "langgenius/file"}},
{"data": {"type": "agent", "agent_strategy_provider_name": "acme/strategy/provider"}},
]
}
assert AppDslService._extract_dependencies_from_workflow_graph(graph) == [
"acme/tool",
"acme/trigger",
"acme/drive",
"acme/strategy",
]
def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None:
workflow = _workflow(
graph={
@@ -737,6 +737,81 @@ def test_export_snippet_dsl_uses_requested_published_workflow(
get_draft_workflow.assert_not_called()
def test_extract_dependencies_from_workflow_graph_covers_plugin_and_model_nodes(service: SnippetDslService) -> None:
graph = {
"nodes": [
{"data": {"type": BuiltinNodeTypes.TOOL, "provider_type": "builtin", "provider_id": "acme/search/search"}},
{
"data": {
"type": BuiltinNodeTypes.TOOL,
"tool_configurations": {"provider_type": "builtin", "provider": "acme/legacy"},
}
},
{"data": {"type": BuiltinNodeTypes.TOOL, "provider_type": "api", "provider_id": "custom-api"}},
{"data": {"type": BuiltinNodeTypes.LLM, "model": {"provider": "acme/llm/llm"}}},
{"data": {"type": "trigger-plugin", "plugin_id": "acme/trigger"}},
{"data": {"type": BuiltinNodeTypes.AGENT, "agent_strategy_provider_name": "acme/agent/agent"}},
]
}
assert service._extract_dependencies_from_workflow_graph(graph) == [
"acme/search",
"acme/legacy",
"acme/llm",
"acme/trigger",
"acme/agent",
]
def test_extract_dependencies_from_workflow_graph_covers_model_variants(service: SnippetDslService) -> None:
graph = {
"nodes": [
{
"data": {
"type": BuiltinNodeTypes.QUESTION_CLASSIFIER,
"model": {"provider": "acme/classifier/classifier"},
}
},
{"data": {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR, "model": {"provider": "acme/extractor/extractor"}}},
{
"data": {
"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
"retrieval_mode": "single",
"single_retrieval_config": {"model": {"provider": "acme/single/single"}},
}
},
{
"data": {
"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
"retrieval_mode": "multiple",
"multiple_retrieval_config": {
"reranking_mode": "reranking_model",
"reranking_model": {"provider": "acme/reranker/reranker"},
},
}
},
{
"data": {
"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
"retrieval_mode": "multiple",
"multiple_retrieval_config": {
"reranking_mode": "weighted_score",
"weights": {"vector_setting": {"embedding_provider_name": "acme/embedding/embedding"}},
},
}
},
]
}
assert service._extract_dependencies_from_workflow_graph(graph) == [
"acme/classifier",
"acme/extractor",
"acme/single",
"acme/reranker",
"acme/embedding",
]
def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(
service: SnippetDslService, monkeypatch: pytest.MonkeyPatch
):