mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
fix(api): include node plugins in DSL dependencies (#42816)
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user